Mypal/devtools/client/inspector/rules/rules.js
2021-02-04 16:48:36 +02:00

1673 lines
55 KiB
JavaScript

/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
/* 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 promise = require("promise");
const Services = require("Services");
const {Task} = require("devtools/shared/task");
const {Tools} = require("devtools/client/definitions");
const {l10n} = require("devtools/shared/inspector/css-logic");
const {ELEMENT_STYLE} = require("devtools/shared/specs/styles");
const {OutputParser} = require("devtools/client/shared/output-parser");
const {PrefObserver, PREF_ORIG_SOURCES} = require("devtools/client/styleeditor/utils");
const {ElementStyle} = require("devtools/client/inspector/rules/models/element-style");
const {Rule} = require("devtools/client/inspector/rules/models/rule");
const {RuleEditor} = require("devtools/client/inspector/rules/views/rule-editor");
const {gDevTools} = require("devtools/client/framework/devtools");
const {getCssProperties} = require("devtools/shared/fronts/css-properties");
const HighlightersOverlay = require("devtools/client/inspector/shared/highlighters-overlay");
const {
VIEW_NODE_SELECTOR_TYPE,
VIEW_NODE_PROPERTY_TYPE,
VIEW_NODE_VALUE_TYPE,
VIEW_NODE_IMAGE_URL_TYPE,
VIEW_NODE_LOCATION_TYPE,
} = require("devtools/client/inspector/shared/node-types");
const StyleInspectorMenu = require("devtools/client/inspector/shared/style-inspector-menu");
const TooltipsOverlay = require("devtools/client/inspector/shared/tooltips-overlay");
const {createChild, promiseWarn, throttle} = require("devtools/client/inspector/shared/utils");
const EventEmitter = require("devtools/shared/event-emitter");
const {KeyShortcuts} = require("devtools/client/shared/key-shortcuts");
const clipboardHelper = require("devtools/shared/platform/clipboard");
const {AutocompletePopup} = require("devtools/client/shared/autocomplete-popup");
const HTML_NS = "http://www.w3.org/1999/xhtml";
const PREF_UA_STYLES = "devtools.inspector.showUserAgentStyles";
const PREF_DEFAULT_COLOR_UNIT = "devtools.defaultColorUnit";
const PREF_ENABLE_MDN_DOCS_TOOLTIP =
"devtools.inspector.mdnDocsTooltip.enabled";
const FILTER_CHANGED_TIMEOUT = 150;
// This is used to parse user input when filtering.
const FILTER_PROP_RE = /\s*([^:\s]*)\s*:\s*(.*?)\s*;?$/;
// This is used to parse the filter search value to see if the filter
// should be strict or not
const FILTER_STRICT_RE = /\s*`(.*?)`\s*$/;
/**
* Our model looks like this:
*
* ElementStyle:
* Responsible for keeping track of which properties are overridden.
* Maintains a list of Rule objects that apply to the element.
* Rule:
* Manages a single style declaration or rule.
* Responsible for applying changes to the properties in a rule.
* Maintains a list of TextProperty objects.
* TextProperty:
* Manages a single property from the authoredText attribute of the
* relevant declaration.
* Maintains a list of computed properties that come from this
* property declaration.
* Changes to the TextProperty are sent to its related Rule for
* application.
*
* View hierarchy mostly follows the model hierarchy.
*
* CssRuleView:
* Owns an ElementStyle and creates a list of RuleEditors for its
* Rules.
* RuleEditor:
* Owns a Rule object and creates a list of TextPropertyEditors
* for its TextProperties.
* Manages creation of new text properties.
* TextPropertyEditor:
* Owns a TextProperty object.
* Manages changes to the TextProperty.
* Can be expanded to display computed properties.
* Can mark a property disabled or enabled.
*/
/**
* CssRuleView is a view of the style rules and declarations that
* apply to a given element. After construction, the 'element'
* property will be available with the user interface.
*
* @param {Inspector} inspector
* Inspector toolbox panel
* @param {Document} document
* The document that will contain the rule view.
* @param {Object} store
* The CSS rule view can use this object to store metadata
* that might outlast the rule view, particularly the current
* set of disabled properties.
* @param {PageStyleFront} pageStyle
* The PageStyleFront for communicating with the remote server.
*/
function CssRuleView(inspector, document, store, pageStyle) {
this.inspector = inspector;
this.styleDocument = document;
this.styleWindow = this.styleDocument.defaultView;
this.store = store || {};
this.pageStyle = pageStyle;
// Allow tests to override throttling behavior, as this can cause intermittents.
this.throttle = throttle;
this.cssProperties = getCssProperties(inspector.toolbox);
this._outputParser = new OutputParser(document, this.cssProperties);
this._onAddRule = this._onAddRule.bind(this);
this._onContextMenu = this._onContextMenu.bind(this);
this._onCopy = this._onCopy.bind(this);
this._onFilterStyles = this._onFilterStyles.bind(this);
this._onClearSearch = this._onClearSearch.bind(this);
this._onTogglePseudoClassPanel = this._onTogglePseudoClassPanel.bind(this);
this._onTogglePseudoClass = this._onTogglePseudoClass.bind(this);
let doc = this.styleDocument;
this.element = doc.getElementById("ruleview-container-focusable");
this.addRuleButton = doc.getElementById("ruleview-add-rule-button");
this.searchField = doc.getElementById("ruleview-searchbox");
this.searchClearButton = doc.getElementById("ruleview-searchinput-clear");
this.pseudoClassPanel = doc.getElementById("pseudo-class-panel");
this.pseudoClassToggle = doc.getElementById("pseudo-class-panel-toggle");
this.hoverCheckbox = doc.getElementById("pseudo-hover-toggle");
this.activeCheckbox = doc.getElementById("pseudo-active-toggle");
this.focusCheckbox = doc.getElementById("pseudo-focus-toggle");
this.searchClearButton.hidden = true;
this.shortcuts = new KeyShortcuts({ window: this.styleWindow });
this._onShortcut = this._onShortcut.bind(this);
this.shortcuts.on("Escape", this._onShortcut);
this.shortcuts.on("Return", this._onShortcut);
this.shortcuts.on("Space", this._onShortcut);
this.shortcuts.on("CmdOrCtrl+F", this._onShortcut);
this.element.addEventListener("copy", this._onCopy);
this.element.addEventListener("contextmenu", this._onContextMenu);
this.addRuleButton.addEventListener("click", this._onAddRule);
this.searchField.addEventListener("input", this._onFilterStyles);
this.searchField.addEventListener("contextmenu", this.inspector.onTextBoxContextMenu);
this.searchClearButton.addEventListener("click", this._onClearSearch);
this.pseudoClassToggle.addEventListener("click",
this._onTogglePseudoClassPanel);
this.hoverCheckbox.addEventListener("click", this._onTogglePseudoClass);
this.activeCheckbox.addEventListener("click", this._onTogglePseudoClass);
this.focusCheckbox.addEventListener("click", this._onTogglePseudoClass);
this._handlePrefChange = this._handlePrefChange.bind(this);
this._onSourcePrefChanged = this._onSourcePrefChanged.bind(this);
this._prefObserver = new PrefObserver("devtools.");
this._prefObserver.on(PREF_ORIG_SOURCES, this._onSourcePrefChanged);
this._prefObserver.on(PREF_UA_STYLES, this._handlePrefChange);
this._prefObserver.on(PREF_DEFAULT_COLOR_UNIT, this._handlePrefChange);
this._prefObserver.on(PREF_ENABLE_MDN_DOCS_TOOLTIP, this._handlePrefChange);
this.showUserAgentStyles = Services.prefs.getBoolPref(PREF_UA_STYLES);
this.enableMdnDocsTooltip =
Services.prefs.getBoolPref(PREF_ENABLE_MDN_DOCS_TOOLTIP);
// The popup will be attached to the toolbox document.
this.popup = new AutocompletePopup(inspector._toolbox.doc, {
autoSelect: true,
theme: "auto"
});
this._showEmpty();
this._contextmenu = new StyleInspectorMenu(this, { isRuleView: true });
// Add the tooltips and highlighters to the view
this.tooltips = new TooltipsOverlay(this);
this.tooltips.addToView();
this.highlighters = new HighlightersOverlay(this);
this.highlighters.addToView();
EventEmitter.decorate(this);
}
CssRuleView.prototype = {
// The element that we're inspecting.
_viewedElement: null,
// Used for cancelling timeouts in the style filter.
_filterChangedTimeout: null,
// Empty, unconnected element of the same type as this node, used
// to figure out how shorthand properties will be parsed.
_dummyElement: null,
// Get the dummy elemenet.
get dummyElement() {
return this._dummyElement;
},
// Get the filter search value.
get searchValue() {
return this.searchField.value.toLowerCase();
},
/**
* Get an instance of SelectorHighlighter (used to highlight nodes that match
* selectors in the rule-view). A new instance is only created the first time
* this function is called. The same instance will then be returned.
*
* @return {Promise} Resolves to the instance of the highlighter.
*/
getSelectorHighlighter: Task.async(function* () {
let utils = this.inspector.toolbox.highlighterUtils;
if (!utils.supportsCustomHighlighters()) {
return null;
}
if (this.selectorHighlighter) {
return this.selectorHighlighter;
}
try {
let h = yield utils.getHighlighterByType("SelectorHighlighter");
this.selectorHighlighter = h;
return h;
} catch (e) {
// The SelectorHighlighter type could not be created in the
// current target. It could be an older server, or a XUL page.
return null;
}
}),
/**
* 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 selector will just
* unhighlight the highlighted nodes.
*
* @param {DOMNode} selectorIcon
* The icon that was clicked to toggle the selector. The
* class 'highlighted' will be added when the selector is
* highlighted.
* @param {String} selector
* The selector used to find nodes in the page.
*/
toggleSelectorHighlighter: function (selectorIcon, selector) {
if (this.lastSelectorIcon) {
this.lastSelectorIcon.classList.remove("highlighted");
}
selectorIcon.classList.remove("highlighted");
this.unhighlightSelector().then(() => {
if (selector !== this.highlighters.selectorHighlighterShown) {
this.highlighters.selectorHighlighterShown = selector;
selectorIcon.classList.add("highlighted");
this.lastSelectorIcon = selectorIcon;
this.highlightSelector(selector).then(() => {
this.emit("ruleview-selectorhighlighter-toggled", true);
}, e => console.error(e));
} else {
this.highlighters.selectorHighlighterShown = null;
this.emit("ruleview-selectorhighlighter-toggled", false);
}
}, e => console.error(e));
},
highlightSelector: Task.async(function* (selector) {
let node = this.inspector.selection.nodeFront;
let highlighter = yield this.getSelectorHighlighter();
if (!highlighter) {
return;
}
yield highlighter.show(node, {
hideInfoBar: true,
hideGuides: true,
selector
});
}),
unhighlightSelector: Task.async(function* () {
let highlighter = yield this.getSelectorHighlighter();
if (!highlighter) {
return;
}
yield highlighter.hide();
}),
/**
* Get the type of a given node in the rule-view
*
* @param {DOMNode} node
* The node which we want information about
* @return {Object} The type information object contains the following props:
* - 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 of the node isn't anything we care about
*/
getNodeInfo: function (node) {
if (!node) {
return null;
}
let type, value;
let classes = node.classList;
let prop = getParentTextProperty(node);
if (classes.contains("ruleview-propertyname") && prop) {
type = VIEW_NODE_PROPERTY_TYPE;
value = {
property: node.textContent,
value: getPropertyNameAndValue(node).value,
enabled: prop.enabled,
overridden: prop.overridden,
pseudoElement: prop.rule.pseudoElement,
sheetHref: prop.rule.domRule.href,
textProperty: prop
};
} else if (classes.contains("ruleview-propertyvalue") && prop) {
type = VIEW_NODE_VALUE_TYPE;
value = {
property: getPropertyNameAndValue(node).name,
value: node.textContent,
enabled: prop.enabled,
overridden: prop.overridden,
pseudoElement: prop.rule.pseudoElement,
sheetHref: prop.rule.domRule.href,
textProperty: prop
};
} else if (classes.contains("theme-link") &&
!classes.contains("ruleview-rule-source") && prop) {
type = VIEW_NODE_IMAGE_URL_TYPE;
value = {
property: getPropertyNameAndValue(node).name,
value: node.parentNode.textContent,
url: node.href,
enabled: prop.enabled,
overridden: prop.overridden,
pseudoElement: prop.rule.pseudoElement,
sheetHref: prop.rule.domRule.href,
textProperty: prop
};
} else if (classes.contains("ruleview-selector-unmatched") ||
classes.contains("ruleview-selector-matched") ||
classes.contains("ruleview-selectorcontainer") ||
classes.contains("ruleview-selector") ||
classes.contains("ruleview-selector-attribute") ||
classes.contains("ruleview-selector-pseudo-class") ||
classes.contains("ruleview-selector-pseudo-class-lock")) {
type = VIEW_NODE_SELECTOR_TYPE;
value = this._getRuleEditorForNode(node).selectorText.textContent;
} else if (classes.contains("ruleview-rule-source") ||
classes.contains("ruleview-rule-source-label")) {
type = VIEW_NODE_LOCATION_TYPE;
let rule = this._getRuleEditorForNode(node).rule;
value = (rule.sheet && rule.sheet.href) ? rule.sheet.href : rule.title;
} else {
return null;
}
return {type, value};
},
/**
* Retrieve the RuleEditor instance that should be stored on
* the offset parent of the node
*/
_getRuleEditorForNode: function (node) {
if (!node.offsetParent) {
// some nodes don't have an offsetParent, but their parentNode does
node = node.parentNode;
}
return node.offsetParent._ruleEditor;
},
/**
* Context menu handler.
*/
_onContextMenu: function (event) {
this._contextmenu.show(event);
},
/**
* Callback for copy event. Copy the selected text.
*
* @param {Event} event
* copy event object.
*/
_onCopy: function (event) {
if (event) {
this.copySelection(event.target);
event.preventDefault();
}
},
/**
* Copy the current selection. The current target is necessary
* if the selection is inside an input or a textarea
*
* @param {DOMNode} target
* DOMNode target of the copy action
*/
copySelection: function (target) {
try {
let text = "";
let nodeName = target && target.nodeName;
if (nodeName === "input" || nodeName == "textarea") {
let start = Math.min(target.selectionStart, target.selectionEnd);
let end = Math.max(target.selectionStart, target.selectionEnd);
let count = end - start;
text = target.value.substr(start, count);
} else {
text = this.styleWindow.getSelection().toString();
// Remove any double newlines.
text = text.replace(/(\r?\n)\r?\n/g, "$1");
}
clipboardHelper.copyString(text);
} catch (e) {
console.error(e);
}
},
/**
* A helper for _onAddRule that handles the case where the actor
* does not support as-authored styles.
*/
_onAddNewRuleNonAuthored: function () {
let elementStyle = this._elementStyle;
let element = elementStyle.element;
let rules = elementStyle.rules;
let pseudoClasses = element.pseudoClassLocks;
this.pageStyle.addNewRule(element, pseudoClasses).then(options => {
let newRule = new Rule(elementStyle, options);
rules.push(newRule);
let editor = new RuleEditor(this, newRule);
newRule.editor = editor;
// Insert the new rule editor after the inline element rule
if (rules.length <= 1) {
this.element.appendChild(editor.element);
} else {
for (let rule of rules) {
if (rule.domRule.type === ELEMENT_STYLE) {
let referenceElement = rule.editor.element.nextSibling;
this.element.insertBefore(editor.element, referenceElement);
break;
}
}
}
// Focus and make the new rule's selector editable
editor.selectorText.click();
elementStyle._changed();
});
},
/**
* Add a new rule to the current element.
*/
_onAddRule: function () {
let elementStyle = this._elementStyle;
let element = elementStyle.element;
let client = this.inspector.target.client;
let pseudoClasses = element.pseudoClassLocks;
if (!client.traits.addNewRule) {
return;
}
if (!this.pageStyle.supportsAuthoredStyles) {
// We're talking to an old server.
this._onAddNewRuleNonAuthored();
return;
}
// Adding a new rule with authored styles will cause the actor to
// emit an event, which will in turn cause the rule view to be
// updated. So, we wait for this update and for the rule creation
// request to complete, and then focus the new rule's selector.
let eventPromise = this.once("ruleview-refreshed");
let newRulePromise = this.pageStyle.addNewRule(element, pseudoClasses);
promise.all([eventPromise, newRulePromise]).then((values) => {
let options = values[1];
// Be sure the reference the correct |rules| here.
for (let rule of this._elementStyle.rules) {
if (options.rule === rule.domRule) {
rule.editor.selectorText.click();
elementStyle._changed();
break;
}
}
});
},
/**
* Disables add rule button when needed
*/
refreshAddRuleButtonState: function () {
let shouldBeDisabled = !this._viewedElement ||
!this.inspector.selection.isElementNode() ||
this.inspector.selection.isAnonymousNode();
this.addRuleButton.disabled = shouldBeDisabled;
},
setPageStyle: function (pageStyle) {
this.pageStyle = pageStyle;
},
/**
* Return {Boolean} true if the rule view currently has an input
* editor visible.
*/
get isEditing() {
return this.tooltips.isEditing ||
this.element.querySelectorAll(".styleinspector-propertyeditor")
.length > 0;
},
_handlePrefChange: function (pref) {
if (pref === PREF_UA_STYLES) {
this.showUserAgentStyles = Services.prefs.getBoolPref(pref);
}
// Reselect the currently selected element
let refreshOnPrefs = [PREF_UA_STYLES, PREF_DEFAULT_COLOR_UNIT];
if (refreshOnPrefs.indexOf(pref) > -1) {
this.selectElement(this._viewedElement, true);
}
},
/**
* Update source links when pref for showing original sources changes
*/
_onSourcePrefChanged: function () {
if (this._elementStyle && this._elementStyle.rules) {
for (let rule of this._elementStyle.rules) {
if (rule.editor) {
rule.editor.updateSourceLink();
}
}
this.inspector.emit("rule-view-sourcelinks-updated");
}
},
/**
* Set the filter style search value.
* @param {String} value
* The search value.
*/
setFilterStyles: function (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: function () {
if (this._filterChangedTimeout) {
clearTimeout(this._filterChangedTimeout);
}
let filterTimeout = (this.searchValue.length > 0) ?
FILTER_CHANGED_TIMEOUT : 0;
this.searchClearButton.hidden = this.searchValue.length === 0;
this._filterChangedTimeout = setTimeout(() => {
if (this.searchField.value.length > 0) {
this.searchField.setAttribute("filled", true);
} else {
this.searchField.removeAttribute("filled");
}
this.searchData = {
searchPropertyMatch: FILTER_PROP_RE.exec(this.searchValue),
searchPropertyName: this.searchValue,
searchPropertyValue: this.searchValue,
strictSearchValue: "",
strictSearchPropertyName: false,
strictSearchPropertyValue: false,
strictSearchAllValues: false
};
if (this.searchData.searchPropertyMatch) {
// Parse search value as a single property line and extract the
// property name and value. If the parsed property name or value is
// contained in backquotes (`), extract the value within the backquotes
// and set the corresponding strict search for the property to true.
if (FILTER_STRICT_RE.test(this.searchData.searchPropertyMatch[1])) {
this.searchData.strictSearchPropertyName = true;
this.searchData.searchPropertyName =
FILTER_STRICT_RE.exec(this.searchData.searchPropertyMatch[1])[1];
} else {
this.searchData.searchPropertyName =
this.searchData.searchPropertyMatch[1];
}
if (FILTER_STRICT_RE.test(this.searchData.searchPropertyMatch[2])) {
this.searchData.strictSearchPropertyValue = true;
this.searchData.searchPropertyValue =
FILTER_STRICT_RE.exec(this.searchData.searchPropertyMatch[2])[1];
} else {
this.searchData.searchPropertyValue =
this.searchData.searchPropertyMatch[2];
}
// Strict search for stylesheets will match the property line regex.
// Extract the search value within the backquotes to be used
// in the strict search for stylesheets in _highlightStyleSheet.
if (FILTER_STRICT_RE.test(this.searchValue)) {
this.searchData.strictSearchValue =
FILTER_STRICT_RE.exec(this.searchValue)[1];
}
} else if (FILTER_STRICT_RE.test(this.searchValue)) {
// If the search value does not correspond to a property line and
// is contained in backquotes, extract the search value within the
// backquotes and set the flag to perform a strict search for all
// the values (selector, stylesheet, property and computed values).
let searchValue = FILTER_STRICT_RE.exec(this.searchValue)[1];
this.searchData.strictSearchAllValues = true;
this.searchData.searchPropertyName = searchValue;
this.searchData.searchPropertyValue = searchValue;
this.searchData.strictSearchValue = searchValue;
}
this._clearHighlight(this.element);
this._clearRules();
this._createEditors();
this.inspector.emit("ruleview-filtered");
this._filterChangeTimeout = 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: function () {
if (this.searchField.value) {
this.setFilterStyles("");
return true;
}
return false;
},
destroy: function () {
this.isDestroyed = true;
this.clear();
this._dummyElement = null;
this._prefObserver.off(PREF_ORIG_SOURCES, this._onSourcePrefChanged);
this._prefObserver.off(PREF_UA_STYLES, this._handlePrefChange);
this._prefObserver.off(PREF_DEFAULT_COLOR_UNIT, this._handlePrefChange);
this._prefObserver.destroy();
this._outputParser = null;
// Remove context menu
if (this._contextmenu) {
this._contextmenu.destroy();
this._contextmenu = null;
}
this.tooltips.destroy();
this.highlighters.destroy();
// Remove bound listeners
this.shortcuts.destroy();
this.element.removeEventListener("copy", this._onCopy);
this.element.removeEventListener("contextmenu", this._onContextMenu);
this.addRuleButton.removeEventListener("click", this._onAddRule);
this.searchField.removeEventListener("input", this._onFilterStyles);
this.searchField.removeEventListener("contextmenu",
this.inspector.onTextBoxContextMenu);
this.searchClearButton.removeEventListener("click", this._onClearSearch);
this.pseudoClassToggle.removeEventListener("click",
this._onTogglePseudoClassPanel);
this.hoverCheckbox.removeEventListener("click", this._onTogglePseudoClass);
this.activeCheckbox.removeEventListener("click", this._onTogglePseudoClass);
this.focusCheckbox.removeEventListener("click", this._onTogglePseudoClass);
this.searchField = null;
this.searchClearButton = null;
this.pseudoClassPanel = null;
this.pseudoClassToggle = null;
this.hoverCheckbox = null;
this.activeCheckbox = null;
this.focusCheckbox = null;
this.inspector = null;
this.styleDocument = null;
this.styleWindow = null;
if (this.element.parentNode) {
this.element.parentNode.removeChild(this.element);
}
if (this._elementStyle) {
this._elementStyle.destroy();
}
this.popup.destroy();
},
/**
* Mark the view as selecting an element, disabling all interaction, and
* visually clearing the view after a few milliseconds to avoid confusion
* about which element's styles the rule view shows.
*/
_startSelectingElement: function () {
this.element.classList.add("non-interactive");
},
/**
* Mark the view as no longer selecting an element, re-enabling interaction.
*/
_stopSelectingElement: function () {
this.element.classList.remove("non-interactive");
},
/**
* Update the view with a new selected element.
*
* @param {NodeActor} element
* The node whose style rules we'll inspect.
* @param {Boolean} allowRefresh
* Update the view even if the element is the same as last time.
*/
selectElement: function (element, allowRefresh = false) {
let refresh = (this._viewedElement === element);
if (refresh && !allowRefresh) {
return promise.resolve(undefined);
}
if (this.popup.isOpen) {
this.popup.hidePopup();
}
this.clear(false);
this._viewedElement = element;
this.clearPseudoClassPanel();
this.refreshAddRuleButtonState();
if (!this._viewedElement) {
this._stopSelectingElement();
this._clearRules();
this._showEmpty();
this.refreshPseudoClassPanel();
return promise.resolve(undefined);
}
// 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.
let dummyElementPromise = promise.resolve(this.styleDocument).then(document => {
// ::before and ::after do not have a namespaceURI
let namespaceURI = this.element.namespaceURI ||
document.documentElement.namespaceURI;
this._dummyElement = document.createElementNS(namespaceURI,
this.element.tagName);
}).then(null, promiseWarn);
let elementStyle = new ElementStyle(element, this, this.store,
this.pageStyle, this.showUserAgentStyles);
this._elementStyle = elementStyle;
this._startSelectingElement();
return dummyElementPromise.then(() => {
if (this._elementStyle === elementStyle) {
return this._populate();
}
return undefined;
}).then(() => {
if (this._elementStyle === elementStyle) {
if (!refresh) {
this.element.scrollTop = 0;
}
this._stopSelectingElement();
this._elementStyle.onChanged = () => {
this._changed();
};
}
}).then(null, e => {
if (this._elementStyle === elementStyle) {
this._stopSelectingElement();
this._clearRules();
}
console.error(e);
});
},
/**
* Update the rules for the currently highlighted element.
*/
refreshPanel: function () {
// Ignore refreshes during editing or when no element is selected.
if (this.isEditing || !this._elementStyle) {
return promise.resolve(undefined);
}
// Repopulate the element style once the current modifications are done.
let promises = [];
for (let rule of this._elementStyle.rules) {
if (rule._applyingModifications) {
promises.push(rule._applyingModifications);
}
}
return promise.all(promises).then(() => {
return this._populate();
});
},
/**
* Clear the pseudo class options panel by removing the checked and disabled
* attributes for each checkbox.
*/
clearPseudoClassPanel: function () {
this.hoverCheckbox.checked = this.hoverCheckbox.disabled = false;
this.activeCheckbox.checked = this.activeCheckbox.disabled = false;
this.focusCheckbox.checked = this.focusCheckbox.disabled = false;
},
/**
* Update the pseudo class options for the currently highlighted element.
*/
refreshPseudoClassPanel: function () {
if (!this._elementStyle || !this.inspector.selection.isElementNode()) {
this.hoverCheckbox.disabled = true;
this.activeCheckbox.disabled = true;
this.focusCheckbox.disabled = true;
return;
}
for (let pseudoClassLock of this._elementStyle.element.pseudoClassLocks) {
switch (pseudoClassLock) {
case ":hover": {
this.hoverCheckbox.checked = true;
break;
}
case ":active": {
this.activeCheckbox.checked = true;
break;
}
case ":focus": {
this.focusCheckbox.checked = true;
break;
}
}
}
},
_populate: function () {
let elementStyle = this._elementStyle;
return this._elementStyle.populate().then(() => {
if (this._elementStyle !== elementStyle || this.isDestroyed) {
return null;
}
this._clearRules();
let onEditorsReady = this._createEditors();
this.refreshPseudoClassPanel();
// Notify anyone that cares that we refreshed.
return onEditorsReady.then(() => {
this.emit("ruleview-refreshed");
}, e => console.error(e));
}).then(null, promiseWarn);
},
/**
* Show the user that the rule view has no node selected.
*/
_showEmpty: function () {
if (this.styleDocument.getElementById("ruleview-no-results")) {
return;
}
createChild(this.element, "div", {
id: "ruleview-no-results",
textContent: l10n("rule.empty")
});
},
/**
* Clear the rules.
*/
_clearRules: function () {
this.element.innerHTML = "";
},
/**
* Clear the rule view.
*/
clear: function (clearDom = true) {
this.lastSelectorIcon = null;
if (clearDom) {
this._clearRules();
}
this._viewedElement = null;
if (this._elementStyle) {
this._elementStyle.destroy();
this._elementStyle = null;
}
},
/**
* Called when the user has made changes to the ElementStyle.
* Emits an event that clients can listen to.
*/
_changed: function () {
this.emit("ruleview-changed");
},
/**
* Text for header that shows above rules for this element
*/
get selectedElementLabel() {
if (this._selectedElementLabel) {
return this._selectedElementLabel;
}
this._selectedElementLabel = l10n("rule.selectedElement");
return this._selectedElementLabel;
},
/**
* Text for header that shows above rules for pseudo elements
*/
get pseudoElementLabel() {
if (this._pseudoElementLabel) {
return this._pseudoElementLabel;
}
this._pseudoElementLabel = l10n("rule.pseudoElement");
return this._pseudoElementLabel;
},
get showPseudoElements() {
if (this._showPseudoElements === undefined) {
this._showPseudoElements =
Services.prefs.getBoolPref("devtools.inspector.show_pseudo_elements");
}
return this._showPseudoElements;
},
/**
* Creates an expandable container in the rule view
*
* @param {String} label
* The label for the container header
* @param {Boolean} isPseudo
* Whether or not the container will hold pseudo element rules
* @return {DOMNode} The container element
*/
createExpandableContainer: function (label, isPseudo = false) {
let header = this.styleDocument.createElementNS(HTML_NS, "div");
header.className = this._getRuleViewHeaderClassName(true);
header.textContent = label;
let twisty = this.styleDocument.createElementNS(HTML_NS, "span");
twisty.className = "ruleview-expander theme-twisty";
twisty.setAttribute("open", "true");
header.insertBefore(twisty, header.firstChild);
this.element.appendChild(header);
let container = this.styleDocument.createElementNS(HTML_NS, "div");
container.classList.add("ruleview-expandable-container");
container.hidden = false;
this.element.appendChild(container);
header.addEventListener("dblclick", () => {
this._toggleContainerVisibility(twisty, container, isPseudo,
!this.showPseudoElements);
}, false);
twisty.addEventListener("click", () => {
this._toggleContainerVisibility(twisty, container, isPseudo,
!this.showPseudoElements);
}, false);
if (isPseudo) {
this._toggleContainerVisibility(twisty, container, isPseudo,
this.showPseudoElements);
}
return container;
},
/**
* Toggle the visibility of an expandable container
*
* @param {DOMNode} twisty
* Clickable toggle DOM Node
* @param {DOMNode} container
* Expandable container DOM Node
* @param {Boolean} isPseudo
* Whether or not the container will hold pseudo element rules
* @param {Boolean} showPseudo
* Whether or not pseudo element rules should be displayed
*/
_toggleContainerVisibility: function (twisty, container, isPseudo,
showPseudo) {
let isOpen = twisty.getAttribute("open");
if (isPseudo) {
this._showPseudoElements = !!showPseudo;
Services.prefs.setBoolPref("devtools.inspector.show_pseudo_elements",
this.showPseudoElements);
container.hidden = !this.showPseudoElements;
isOpen = !this.showPseudoElements;
} else {
container.hidden = !container.hidden;
}
if (isOpen) {
twisty.removeAttribute("open");
} else {
twisty.setAttribute("open", "true");
}
},
_getRuleViewHeaderClassName: function (isPseudo) {
let baseClassName = "theme-gutter ruleview-header";
return isPseudo ? baseClassName + " ruleview-expandable-header" :
baseClassName;
},
/**
* Creates editor UI for each of the rules in _elementStyle.
*/
_createEditors: function () {
// Run through the current list of rules, attaching
// their editors in order. Create editors if needed.
let lastInheritedSource = "";
let lastKeyframes = null;
let seenPseudoElement = false;
let seenNormalElement = false;
let seenSearchTerm = false;
let container = null;
if (!this._elementStyle.rules) {
return promise.resolve();
}
let editorReadyPromises = [];
for (let rule of this._elementStyle.rules) {
if (rule.domRule.system) {
continue;
}
// Initialize rule editor
if (!rule.editor) {
rule.editor = new RuleEditor(this, rule);
editorReadyPromises.push(rule.editor.once("source-link-updated"));
}
// Filter the rules and highlight any matches if there is a search input
if (this.searchValue && this.searchData) {
if (this.highlightRule(rule)) {
seenSearchTerm = true;
} else if (rule.domRule.type !== ELEMENT_STYLE) {
continue;
}
}
// Only print header for this element if there are pseudo elements
if (seenPseudoElement && !seenNormalElement && !rule.pseudoElement) {
seenNormalElement = true;
let div = this.styleDocument.createElementNS(HTML_NS, "div");
div.className = this._getRuleViewHeaderClassName();
div.textContent = this.selectedElementLabel;
this.element.appendChild(div);
}
let inheritedSource = rule.inheritedSource;
if (inheritedSource && inheritedSource !== lastInheritedSource) {
let div = this.styleDocument.createElementNS(HTML_NS, "div");
div.className = this._getRuleViewHeaderClassName();
div.textContent = inheritedSource;
lastInheritedSource = inheritedSource;
this.element.appendChild(div);
}
if (!seenPseudoElement && rule.pseudoElement) {
seenPseudoElement = true;
container = this.createExpandableContainer(this.pseudoElementLabel,
true);
}
let keyframes = rule.keyframes;
if (keyframes && keyframes !== lastKeyframes) {
lastKeyframes = keyframes;
container = this.createExpandableContainer(rule.keyframesName);
}
if (container && (rule.pseudoElement || keyframes)) {
container.appendChild(rule.editor.element);
} else {
this.element.appendChild(rule.editor.element);
}
}
if (this.searchValue && !seenSearchTerm) {
this.searchField.classList.add("devtools-style-searchbox-no-match");
} else {
this.searchField.classList.remove("devtools-style-searchbox-no-match");
}
return promise.all(editorReadyPromises);
},
/**
* Highlight rules that matches the filter search value and returns a
* boolean indicating whether or not rules were highlighted.
*
* @param {Rule} rule
* The rule object we're highlighting if its rule selectors or
* property values match the search value.
* @return {Boolean} true if the rule was highlighted, false otherwise.
*/
highlightRule: function (rule) {
let isRuleSelectorHighlighted = this._highlightRuleSelector(rule);
let isStyleSheetHighlighted = this._highlightStyleSheet(rule);
let isHighlighted = isRuleSelectorHighlighted || isStyleSheetHighlighted;
// Highlight search matches in the rule properties
for (let textProp of rule.textProps) {
if (!textProp.invisible && this._highlightProperty(textProp.editor)) {
isHighlighted = true;
}
}
return isHighlighted;
},
/**
* Highlights the rule selector that matches the filter search value and
* returns a boolean indicating whether or not the selector was highlighted.
*
* @param {Rule} rule
* The Rule object.
* @return {Boolean} true if the rule selector was highlighted,
* false otherwise.
*/
_highlightRuleSelector: function (rule) {
let isSelectorHighlighted = false;
let selectorNodes = [...rule.editor.selectorText.childNodes];
if (rule.domRule.type === CSSRule.KEYFRAME_RULE) {
selectorNodes = [rule.editor.selectorText];
} else if (rule.domRule.type === ELEMENT_STYLE) {
selectorNodes = [];
}
// Highlight search matches in the rule selectors
for (let selectorNode of selectorNodes) {
let selector = selectorNode.textContent.toLowerCase();
if ((this.searchData.strictSearchAllValues &&
selector === this.searchData.strictSearchValue) ||
(!this.searchData.strictSearchAllValues &&
selector.includes(this.searchValue))) {
selectorNode.classList.add("ruleview-highlight");
isSelectorHighlighted = true;
}
}
return isSelectorHighlighted;
},
/**
* Highlights the stylesheet source that matches the filter search value and
* returns a boolean indicating whether or not the stylesheet source was
* highlighted.
*
* @return {Boolean} true if the stylesheet source was highlighted, false
* otherwise.
*/
_highlightStyleSheet: function (rule) {
let styleSheetSource = rule.title.toLowerCase();
let isStyleSheetHighlighted = this.searchData.strictSearchValue ?
styleSheetSource === this.searchData.strictSearchValue :
styleSheetSource.includes(this.searchValue);
if (isStyleSheetHighlighted) {
rule.editor.source.classList.add("ruleview-highlight");
}
return isStyleSheetHighlighted;
},
/**
* Highlights the rule properties and computed properties that match the
* filter search value and returns a boolean indicating whether or not the
* property or computed property was highlighted.
*
* @param {TextPropertyEditor} editor
* The rule property TextPropertyEditor object.
* @return {Boolean} true if the property or computed property was
* highlighted, false otherwise.
*/
_highlightProperty: function (editor) {
let isPropertyHighlighted = this._highlightRuleProperty(editor);
let isComputedHighlighted = this._highlightComputedProperty(editor);
// Expand the computed list if a computed property is highlighted and the
// property rule is not highlighted
if (!isPropertyHighlighted && isComputedHighlighted &&
!editor.computed.hasAttribute("user-open")) {
editor.expandForFilter();
}
return isPropertyHighlighted || isComputedHighlighted;
},
/**
* Called when TextPropertyEditor is updated and updates the rule property
* highlight.
*
* @param {TextPropertyEditor} editor
* The rule property TextPropertyEditor object.
*/
_updatePropertyHighlight: function (editor) {
if (!this.searchValue || !this.searchData) {
return;
}
this._clearHighlight(editor.element);
if (this._highlightProperty(editor)) {
this.searchField.classList.remove("devtools-style-searchbox-no-match");
}
},
/**
* Highlights the rule property that matches the filter search value
* and returns a boolean indicating whether or not the property was
* highlighted.
*
* @param {TextPropertyEditor} editor
* The rule property TextPropertyEditor object.
* @return {Boolean} true if the rule property was highlighted,
* false otherwise.
*/
_highlightRuleProperty: function (editor) {
// Get the actual property value displayed in the rule view
let propertyName = editor.prop.name.toLowerCase();
let propertyValue = editor.valueSpan.textContent.toLowerCase();
return this._highlightMatches(editor.container, propertyName,
propertyValue);
},
/**
* Highlights the computed property that matches the filter search value and
* returns a boolean indicating whether or not the computed property was
* highlighted.
*
* @param {TextPropertyEditor} editor
* The rule property TextPropertyEditor object.
* @return {Boolean} true if the computed property was highlighted, false
* otherwise.
*/
_highlightComputedProperty: function (editor) {
let isComputedHighlighted = false;
// Highlight search matches in the computed list of properties
editor._populateComputed();
for (let computed of editor.prop.computed) {
if (computed.element) {
// Get the actual property value displayed in the computed list
let computedName = computed.name.toLowerCase();
let computedValue = computed.parsedValue.toLowerCase();
isComputedHighlighted = this._highlightMatches(computed.element,
computedName, computedValue) ? true : isComputedHighlighted;
}
}
return isComputedHighlighted;
},
/**
* Helper function for highlightRules that carries out highlighting the given
* element if the search terms match the property, and returns a boolean
* indicating whether or not the search terms match.
*
* @param {DOMNode} element
* The node to highlight if search terms match
* @param {String} propertyName
* The property name of a rule
* @param {String} propertyValue
* The property value of a rule
* @return {Boolean} true if the given search terms match the property, false
* otherwise.
*/
_highlightMatches: function (element, propertyName, propertyValue) {
let {
searchPropertyName,
searchPropertyValue,
searchPropertyMatch,
strictSearchPropertyName,
strictSearchPropertyValue,
strictSearchAllValues,
} = this.searchData;
let matches = false;
// If the inputted search value matches a property line like
// `font-family: arial`, then check to make sure the name and value match.
// Otherwise, just compare the inputted search string directly against the
// name and value of the rule property.
let hasNameAndValue = searchPropertyMatch &&
searchPropertyName &&
searchPropertyValue;
let isMatch = (value, query, isStrict) => {
return isStrict ? value === query : query && value.includes(query);
};
if (hasNameAndValue) {
matches =
isMatch(propertyName, searchPropertyName, strictSearchPropertyName) &&
isMatch(propertyValue, searchPropertyValue, strictSearchPropertyValue);
} else {
matches =
isMatch(propertyName, searchPropertyName,
strictSearchPropertyName || strictSearchAllValues) ||
isMatch(propertyValue, searchPropertyValue,
strictSearchPropertyValue || strictSearchAllValues);
}
if (matches) {
element.classList.add("ruleview-highlight");
}
return matches;
},
/**
* Clear all search filter highlights in the panel, and close the computed
* list if toggled opened
*/
_clearHighlight: function (element) {
for (let el of element.querySelectorAll(".ruleview-highlight")) {
el.classList.remove("ruleview-highlight");
}
for (let computed of element.querySelectorAll(
".ruleview-computedlist[filter-open]")) {
computed.parentNode._textPropertyEditor.collapseForFilter();
}
},
/**
* Called when the pseudo class panel button is clicked and toggles
* the display of the pseudo class panel.
*/
_onTogglePseudoClassPanel: function () {
if (this.pseudoClassPanel.hidden) {
this.pseudoClassToggle.setAttribute("checked", "true");
this.hoverCheckbox.setAttribute("tabindex", "0");
this.activeCheckbox.setAttribute("tabindex", "0");
this.focusCheckbox.setAttribute("tabindex", "0");
} else {
this.pseudoClassToggle.removeAttribute("checked");
this.hoverCheckbox.setAttribute("tabindex", "-1");
this.activeCheckbox.setAttribute("tabindex", "-1");
this.focusCheckbox.setAttribute("tabindex", "-1");
}
this.pseudoClassPanel.hidden = !this.pseudoClassPanel.hidden;
},
/**
* Called when a pseudo class checkbox is clicked and toggles
* the pseudo class for the current selected element.
*/
_onTogglePseudoClass: function (event) {
let target = event.currentTarget;
this.inspector.togglePseudoClass(target.value);
},
/**
* Handle the keypress event in the rule view.
*/
_onShortcut: function (name, event) {
if (!event.target.closest("#sidebar-panel-ruleview")) {
return;
}
if (name === "CmdOrCtrl+F") {
this.searchField.focus();
event.preventDefault();
} else if ((name === "Return" || name === "Space") &&
this.element.classList.contains("non-interactive")) {
event.preventDefault();
} else if (name === "Escape" &&
event.target === this.searchField &&
this._onClearSearch()) {
// Handle the search box's keypress event. If the escape key is pressed,
// clear the search box field.
event.preventDefault();
event.stopPropagation();
}
}
};
/**
* Helper functions
*/
/**
* Walk up the DOM from a given node until a parent property holder is found.
* For elements inside the computed property list, the non-computed parent
* property holder will be returned
*
* @param {DOMNode} node
* The node to start from
* @return {DOMNode} The parent property holder node, or null if not found
*/
function getParentTextPropertyHolder(node) {
while (true) {
if (!node || !node.classList) {
return null;
}
if (node.classList.contains("ruleview-property")) {
return node;
}
node = node.parentNode;
}
}
/**
* For any given node, find the TextProperty it is in if any
* @param {DOMNode} node
* The node to start from
* @return {TextProperty}
*/
function getParentTextProperty(node) {
let parent = getParentTextPropertyHolder(node);
if (!parent) {
return null;
}
let propValue = parent.querySelector(".ruleview-propertyvalue");
if (!propValue) {
return null;
}
return propValue.textProperty;
}
/**
* Walker up the DOM from a given node until a parent property holder is found,
* and return the textContent for the name and value nodes.
* Stops at the first property found, so if node is inside the computed property
* list, the computed property will be returned
*
* @param {DOMNode} node
* The node to start from
* @return {Object} {name, value}
*/
function getPropertyNameAndValue(node) {
while (true) {
if (!node || !node.classList) {
return null;
}
// Check first for ruleview-computed since it's the deepest
if (node.classList.contains("ruleview-computed") ||
node.classList.contains("ruleview-property")) {
return {
name: node.querySelector(".ruleview-propertyname").textContent,
value: node.querySelector(".ruleview-propertyvalue").textContent
};
}
node = node.parentNode;
}
}
function RuleViewTool(inspector, window) {
this.inspector = inspector;
this.document = window.document;
this.view = new CssRuleView(this.inspector, this.document);
this.clearUserProperties = this.clearUserProperties.bind(this);
this.refresh = this.refresh.bind(this);
this.onLinkClicked = this.onLinkClicked.bind(this);
this.onMutations = this.onMutations.bind(this);
this.onPanelSelected = this.onPanelSelected.bind(this);
this.onPropertyChanged = this.onPropertyChanged.bind(this);
this.onResized = this.onResized.bind(this);
this.onSelected = this.onSelected.bind(this);
this.onViewRefreshed = this.onViewRefreshed.bind(this);
this.view.on("ruleview-changed", this.onPropertyChanged);
this.view.on("ruleview-refreshed", this.onViewRefreshed);
this.view.on("ruleview-linked-clicked", this.onLinkClicked);
this.inspector.selection.on("detached-front", this.onSelected);
this.inspector.selection.on("new-node-front", this.onSelected);
this.inspector.selection.on("pseudoclass", this.refresh);
this.inspector.target.on("navigate", this.clearUserProperties);
this.inspector.sidebar.on("ruleview-selected", this.onPanelSelected);
this.inspector.pageStyle.on("stylesheet-updated", this.refresh);
this.inspector.walker.on("mutations", this.onMutations);
this.inspector.walker.on("resize", this.onResized);
this.onSelected();
}
RuleViewTool.prototype = {
isSidebarActive: function () {
if (!this.view) {
return false;
}
return this.inspector.sidebar.getCurrentTabID() == "ruleview";
},
onSelected: function (event) {
// 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.view) {
return;
}
let isInactive = !this.isSidebarActive() &&
this.inspector.selection.nodeFront;
if (isInactive) {
return;
}
this.view.setPageStyle(this.inspector.pageStyle);
if (!this.inspector.selection.isConnected() ||
!this.inspector.selection.isElementNode()) {
this.view.selectElement(null);
return;
}
if (!event || event == "new-node-front") {
let done = this.inspector.updating("rule-view");
this.view.selectElement(this.inspector.selection.nodeFront)
.then(done, done);
}
},
refresh: function () {
if (this.isSidebarActive()) {
this.view.refreshPanel();
}
},
clearUserProperties: function () {
if (this.view && this.view.store && this.view.store.userProperties) {
this.view.store.userProperties.clear();
}
},
onPanelSelected: function () {
if (this.inspector.selection.nodeFront === this.view._viewedElement) {
this.refresh();
} else {
this.onSelected();
}
},
onLinkClicked: function (e, rule) {
let sheet = rule.parentStyleSheet;
// Chrome stylesheets are not listed in the style editor, so show
// these sheets in the view source window instead.
if (!sheet || sheet.isSystem) {
let href = rule.nodeHref || rule.href;
let toolbox = gDevTools.getToolbox(this.inspector.target);
toolbox.viewSource(href, rule.line);
return;
}
let location = promise.resolve(rule.location);
if (Services.prefs.getBoolPref(PREF_ORIG_SOURCES)) {
location = rule.getOriginalLocation();
}
location.then(({ source, href, line, column }) => {
let target = this.inspector.target;
if (Tools.styleEditor.isTargetSupported(target)) {
gDevTools.showToolbox(target, "styleeditor").then(function (toolbox) {
let url = source || href;
toolbox.getCurrentPanel().selectStyleSheet(url, line, column);
});
}
return;
});
},
onPropertyChanged: function () {
this.inspector.markDirty();
},
onViewRefreshed: function () {
this.inspector.emit("rule-view-refreshed");
},
/**
* When markup mutations occur, if an attribute of the selected node changes,
* we need to refresh the view as that might change the node's styles.
*/
onMutations: function (mutations) {
for (let {type, target} of mutations) {
if (target === this.inspector.selection.nodeFront &&
type === "attributes") {
this.refresh();
break;
}
}
},
/**
* When the window gets resized, this may cause media-queries to match, and
* therefore, different styles may apply.
*/
onResized: function () {
this.refresh();
},
destroy: function () {
this.inspector.walker.off("mutations", this.onMutations);
this.inspector.walker.off("resize", this.onResized);
this.inspector.selection.off("detached-front", this.onSelected);
this.inspector.selection.off("pseudoclass", this.refresh);
this.inspector.selection.off("new-node-front", this.onSelected);
this.inspector.target.off("navigate", this.clearUserProperties);
this.inspector.sidebar.off("ruleview-selected", this.onPanelSelected);
if (this.inspector.pageStyle) {
this.inspector.pageStyle.off("stylesheet-updated", this.refresh);
}
this.view.off("ruleview-linked-clicked", this.onLinkClicked);
this.view.off("ruleview-changed", this.onPropertyChanged);
this.view.off("ruleview-refreshed", this.onViewRefreshed);
this.view.destroy();
this.view = this.document = this.inspector = null;
}
};
exports.CssRuleView = CssRuleView;
exports.RuleViewTool = RuleViewTool;