/* 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 FILTER_CHANGED_TIMEOUT = 150; /** * Helper for long-running processes that should yield occasionally to * the mainloop. * * @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. */ function UpdateProcess(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; this.canceled = false; } UpdateProcess.prototype = { /** * Error thrown when the array of items to process is empty. */ ERROR_ITERATION_DONE: new Error("UpdateProcess iteration done"), /** * Schedule a new batch on the main loop. */ schedule() { if (this.canceled) { return; } this._timeout = setTimeout(this._timeoutHandler.bind(this), 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 = 0; } this.canceled = true; this.onCancel(); }, _timeoutHandler() { this._timeout = null; try { this._runBatch(); this.schedule(); } catch (e) { if (e === this.ERROR_ITERATION_DONE) { this.onBatch(); this.onDone(); return; } console.error(e); throw e; } }, _runBatch() { const time = Date.now(); while (!this.canceled) { const next = this._next(); this.onItem(next); if (Date.now() - time > this.threshold) { this.onBatch(); return; } } }, /** * Returns the item at the current index and increases the index. * If all items have already been processed, will throw ERROR_ITERATION_DONE. */ _next() { if (this.index < this.array.length) { return this.array[this.index++]; } throw this.ERROR_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). * * @param {Inspector} inspector * Inspector toolbox panel * @param {Document} document * The document that will contain the computed view. */ function CssComputedView(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._onClearSearch = this._onClearSearch.bind(this); this._onClick = this._onClick.bind(this); this._onContextMenu = this._onContextMenu.bind(this); this._onCopy = this._onCopy.bind(this); this._onFilterStyles = this._onFilterStyles.bind(this); this._onIncludeBrowserStyles = this._onIncludeBrowserStyles.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.shortcuts = new KeyShortcuts({ window: this.styleWindow }); this._onShortcut = this._onShortcut.bind(this); this.shortcuts.on("CmdOrCtrl+F", event => this._onShortcut("CmdOrCtrl+F", event) ); this.shortcuts.on("Escape", event => this._onShortcut("Escape", event)); this.styleDocument.addEventListener("copy", this._onCopy); this.styleDocument.addEventListener("mousedown", this.focusWindow); this.element.addEventListener("click", this._onClick); this.element.addEventListener("contextmenu", this._onContextMenu); this.searchField.addEventListener("input", this._onFilterStyles); this.searchClearButton.addEventListener("click", this._onClearSearch); this.includeBrowserStylesCheckbox.addEventListener( "input", this._onIncludeBrowserStyles ); 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 } ); } 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.on( "ruleview-added", () => { this.ruleView.on("ruleview-changed", this.refreshPanel); }, { once: true } ); } if (this.ruleView) { this.ruleView.on("ruleview-changed", this.refreshPanel); } 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._handlePrefChange = this._handlePrefChange.bind(this); this._prefObserver = new PrefObserver("devtools."); this._prefObserver.on("devtools.defaultColorUnit", this._handlePrefChange); // The element that we're inspecting, and the document that it comes from. this._viewedElement = null; // 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. */ CssComputedView.l10n = function (name) { try { return STYLE_INSPECTOR_L10N.getStr(name); } catch (ex) { console.log("Error reading '" + name + "'"); throw new Error("l10n error with " + name); } }; CssComputedView.prototype = { // Cache the list of properties that match the selected element. _matchedProperties: null, // Used for cancelling timeouts in the style filter. _filterChangedTimeout: null, // Holds the ID of the panelRefresh timeout. _panelRefreshTimeout: null, // Number of visible properties numVisibleProperties: 0, 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 ); }, _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); 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. */ refreshPanel() { if (!this._viewedElement || !this.isPanelVisible()) { return Promise.resolve(); } // Capture the current viewed element to return from the promise handler // early if it changed const viewedElement = this._viewedElement; return Promise.all([ this._createPropertyViews(), this.viewedElementPageStyle.getComputed(this._viewedElement, { filter: this._sourceFilter, onlyMatched: !this.includeBrowserStyles, markMatched: true, }), ]) .then(([, computed]) => { if (viewedElement !== this._viewedElement) { return Promise.resolve(); } 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; return 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(console.error); }, /** * 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._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() { 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; if (this.viewedElementPageStyle) { this.viewedElementPageStyle.off("stylesheet-updated", this.refreshPanel); this.viewedElementPageStyle = null; } this._outputParser = null; this._prefObserver.off("devtools.defaultColorUnit", this._handlePrefChange); 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(); // Remove bound listeners this.element.removeEventListener("click", this._onClick); this.element.removeEventListener("contextmenu", this._onContextMenu); this.searchField.removeEventListener("input", this._onFilterStyles); this.searchClearButton.removeEventListener("click", this._onClearSearch); this.styleDocument.removeEventListener("copy", this._onCopy); this.styleDocument.removeEventListener("mousedown", this.focusWindow); this.includeBrowserStylesCheckbox.removeEventListener( "input", this._onIncludeBrowserStyles ); if (this.ruleView) { this.ruleView.off("ruleview-changed", this.refreshPanel); } // 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; }, }; function PropertyInfo(tree, name) { this.tree = tree; this.name = name; } PropertyInfo.prototype = { 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.link = "https://developer.mozilla.org/docs/Web/CSS/" + name; } 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; // Property header node propertyHeader = null; // Destination for property values valueNode = null; // Are matched rules expanded? matchedExpanded = false; // Matched selector container matchedSelectorsContainer = 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; /** * 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"; } /** * Create DOM elements for a property * * @return {Element} The