/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ /* vim: set ts=2 et sw=2 tw=80: */ /* 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 ToolDefinitions = require("devtools/client/definitions").Tools; const CssLogic = require("devtools/shared/inspector/css-logic"); const {ELEMENT_STYLE} = require("devtools/shared/specs/styles"); const promise = require("promise"); const defer = require("devtools/shared/defer"); const Services = require("Services"); const {OutputParser} = require("devtools/client/shared/output-parser"); const {PrefObserver, PREF_ORIG_SOURCES} = require("devtools/client/styleeditor/utils"); const {createChild} = require("devtools/client/inspector/shared/utils"); 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, } = 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 {KeyShortcuts} = require("devtools/client/shared/key-shortcuts"); const {BoxModelView} = require("devtools/client/inspector/components/box-model"); const clipboardHelper = require("devtools/shared/platform/clipboard"); const STYLE_INSPECTOR_PROPERTIES = "devtools/shared/locales/styleinspector.properties"; const {LocalizationHelper} = require("devtools/shared/l10n"); const STYLE_INSPECTOR_L10N = new LocalizationHelper(STYLE_INSPECTOR_PROPERTIES); const FILTER_CHANGED_TIMEOUT = 150; const HTML_NS = "http://www.w3.org/1999/xhtml"; /** * 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: function () { 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: function () { if (this._timeout) { clearTimeout(this._timeout); this._timeout = 0; } this.canceled = true; this.onCancel(); }, _timeoutHandler: function () { 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: function () { let time = Date.now(); while (!this.canceled) { let 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: function () { 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. * @param {PageStyleFront} pageStyle * Front for the page style actor that will be providing * the style information. */ function CssComputedView(inspector, document, pageStyle) { this.inspector = inspector; this.styleDocument = document; this.styleWindow = this.styleDocument.defaultView; this.pageStyle = pageStyle; this.propertyViews = []; let cssProperties = getCssProperties(inspector.toolbox); this._outputParser = new OutputParser(document, cssProperties); // Create bound methods. this.focusWindow = this.focusWindow.bind(this); this._onContextMenu = this._onContextMenu.bind(this); this._onClick = this._onClick.bind(this); this._onCopy = this._onCopy.bind(this); this._onFilterStyles = this._onFilterStyles.bind(this); this._onClearSearch = this._onClearSearch.bind(this); this._onIncludeBrowserStyles = this._onIncludeBrowserStyles.bind(this); let doc = this.styleDocument; this.element = doc.getElementById("propertyContainer"); this.searchField = doc.getElementById("computedview-searchbox"); this.searchClearButton = doc.getElementById("computedview-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", this._onShortcut); this.shortcuts.on("Escape", this._onShortcut); this.styleDocument.addEventListener("mousedown", this.focusWindow); this.element.addEventListener("click", this._onClick); this.element.addEventListener("copy", this._onCopy); this.element.addEventListener("contextmenu", this._onContextMenu); this.searchField.addEventListener("input", this._onFilterStyles); this.searchField.addEventListener("contextmenu", this.inspector.onTextBoxContextMenu); this.searchClearButton.addEventListener("click", this._onClearSearch); this.includeBrowserStylesCheckbox.addEventListener("input", this._onIncludeBrowserStyles); this.searchClearButton.hidden = true; // No results text. this.noResults = this.styleDocument.getElementById("computedview-no-results"); // Refresh panel when color unit changed. this._handlePrefChange = this._handlePrefChange.bind(this); gDevTools.on("pref-changed", this._handlePrefChange); // Refresh panel when pref for showing original sources changes this._onSourcePrefChanged = this._onSourcePrefChanged.bind(this); this._prefObserver = new PrefObserver("devtools."); this._prefObserver.on(PREF_ORIG_SOURCES, this._onSourcePrefChanged); // The element that we're inspecting, and the document that it comes from. this._viewedElement = null; this.createStyleViews(); this._contextmenu = new StyleInspectorMenu(this, { isRuleView: false }); // Add the tooltips and highlightersoverlay this.tooltips = new TooltipsOverlay(this); this.tooltips.addToView(); this.highlighters = new HighlightersOverlay(this); this.highlighters.addToView(); } /** * 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, // Toggle for zebra striping _darkStripe: true, // Number of visible properties numVisibleProperties: 0, setPageStyle: function (pageStyle) { this.pageStyle = pageStyle; }, get includeBrowserStyles() { return this.includeBrowserStylesCheckbox.checked; }, _handlePrefChange: function (event, data) { if (this._computed && (data.pref === "devtools.defaultColorUnit" || data.pref === PREF_ORIG_SOURCES)) { 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: function (element) { if (!element) { this._viewedElement = null; this.noResults.hidden = false; if (this._refreshProcess) { this._refreshProcess.cancel(); } // Hiding all properties for (let propView of this.propertyViews) { propView.refresh(); } return promise.resolve(undefined); } if (element === this._viewedElement) { return promise.resolve(undefined); } 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: * - 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 */ getNodeInfo: function (node) { if (!node) { return null; } let 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 (let child of node.childNodes) { if (child.nodeType === node.TEXT_NODE) { selectorText += child.textContent; } } return { type: VIEW_NODE_SELECTOR_TYPE, value: selectorText.trim() }; } // Walk up the nodes to find out where node is let propertyView; let propertyContent; let parent = node; while (parent.parentNode) { if (parent.classList.contains("property-view")) { propertyView = parent; break; } if (parent.classList.contains("property-content")) { propertyContent = parent; break; } parent = parent.parentNode; } if (!propertyView && !propertyContent) { return null; } let value, type; // Get the property and value for a node that's a property name or value let isHref = classes.contains("theme-link") && !classes.contains("link"); if (propertyView && (classes.contains("property-name") || classes.contains("property-value") || isHref)) { value = { property: parent.querySelector(".property-name").textContent, value: parent.querySelector(".property-value").textContent }; } if (propertyContent && (classes.contains("other-property-value") || isHref)) { let view = propertyContent.previousSibling; value = { property: view.querySelector(".property-name").textContent, value: node.textContent }; } // Get the type if (classes.contains("property-name")) { type = VIEW_NODE_PROPERTY_TYPE; } else if (classes.contains("property-value") || classes.contains("other-property-value")) { type = VIEW_NODE_VALUE_TYPE; } else if (isHref) { type = VIEW_NODE_IMAGE_URL_TYPE; value.url = node.href; } else { return null; } return {type, value}; }, _createPropertyViews: function () { if (this._createViewsPromise) { return this._createViewsPromise; } let deferred = defer(); this._createViewsPromise = deferred.promise; this.refreshSourceFilter(); this.numVisibleProperties = 0; let fragment = this.styleDocument.createDocumentFragment(); this._createViewsProcess = new UpdateProcess( this.styleWindow, CssComputedView.propertyNames, { onItem: (propertyName) => { // Per-item callback. let propView = new PropertyView(this, propertyName); fragment.appendChild(propView.buildMain()); fragment.appendChild(propView.buildSelectorContainer()); if (propView.visible) { this.numVisibleProperties++; } this.propertyViews.push(propView); }, onCancel: () => { deferred.reject("_createPropertyViews cancelled"); }, onDone: () => { // Completed callback. this.element.appendChild(fragment); this.noResults.hidden = this.numVisibleProperties > 0; deferred.resolve(undefined); } } ); this._createViewsProcess.schedule(); return deferred.promise; }, /** * Refresh the panel content. */ refreshPanel: function () { if (!this._viewedElement) { return promise.resolve(); } // Capture the current viewed element to return from the promise handler // early if it changed let viewedElement = this._viewedElement; return promise.all([ this._createPropertyViews(), this.pageStyle.getComputed(this._viewedElement, { filter: this._sourceFilter, onlyMatched: !this.includeBrowserStyles, markMatched: true }) ]).then(([, computed]) => { if (viewedElement !== this._viewedElement) { return promise.resolve(); } this._matchedProperties = new Set(); for (let name in computed) { if (computed[name].matched) { this._matchedProperties.add(name); } } this._computed = computed; if (this._refreshProcess) { this._refreshProcess.cancel(); } this.noResults.hidden = true; // Reset visible property count this.numVisibleProperties = 0; // Reset zebra striping. this._darkStripe = true; let deferred = defer(); this._refreshProcess = new UpdateProcess( this.styleWindow, this.propertyViews, { onItem: (propView) => { propView.refresh(); }, onCancel: () => { deferred.reject("_refreshProcess of computed view cancelled"); }, onDone: () => { this._refreshProcess = null; this.noResults.hidden = this.numVisibleProperties > 0; if (this.searchField.value.length > 0 && !this.numVisibleProperties) { this.searchField.classList .add("devtools-style-searchbox-no-match"); } else { this.searchField.classList .remove("devtools-style-searchbox-no-match"); } this.inspector.emit("computed-view-refreshed"); deferred.resolve(undefined); } } ); this._refreshProcess.schedule(); return deferred.promise; }).then(null, (err) => console.error(err)); }, /** * Handle the shortcut events in the computed view. */ _onShortcut: function (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: 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.searchField.value.length > 0) ? FILTER_CHANGED_TIMEOUT : 0; this.searchClearButton.hidden = this.searchField.value.length === 0; this._filterChangedTimeout = setTimeout(() => { if (this.searchField.value.length > 0) { this.searchField.setAttribute("filled", true); this.inspector.emit("computed-view-filtered", true); } else { this.searchField.removeAttribute("filled"); this.inspector.emit("computed-view-filtered", false); } 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: function () { if (this.searchField.value) { this.setFilterStyles(""); return true; } return false; }, /** * The change event handler for the includeBrowserStyles checkbox. */ _onIncludeBrowserStyles: function () { 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: function () { this._matchedProperties = null; this._sourceFilter = this.includeBrowserStyles ? CssLogic.FILTER.UA : CssLogic.FILTER.USER; }, _onSourcePrefChanged: function () { for (let propView of this.propertyViews) { propView.updateSourceLinks(); } this.inspector.emit("computed-view-sourcelinks-updated"); }, /** * The CSS as displayed by the UI. */ createStyleViews: function () { 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 let styles = this.styleWindow .getComputedStyle(this.styleDocument.documentElement); let mozProps = []; for (let i = 0, numStyles = styles.length; i < numStyles; i++) { let 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().then(null, 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: function () { this.styleWindow.focus(); }, /** * Context menu handler. */ _onContextMenu: function (event) { this._contextmenu.show(event); }, _onClick: function (event) { let target = event.target; if (target.nodeName === "a") { event.stopPropagation(); event.preventDefault(); let browserWin = this.inspector.target.tab.ownerDocument.defaultView; browserWin.openUILinkIn(target.href, "tab"); } }, /** * Callback for copy event. Copy selected text. * * @param {Event} event * copy event object. */ _onCopy: function (event) { this.copySelection(); event.preventDefault(); }, /** * Copy the current selection to the clipboard */ copySelection: function () { try { let win = this.styleWindow; let text = win.getSelection().toString().trim(); // Tidy up block headings by moving CSS property names and their // values onto the same line and inserting a colon between them. let textArray = text.split(/[\r\n]+/); let result = ""; // Parse text array to output string. if (textArray.length > 1) { for (let prop of textArray) { if (CssComputedView.propertyNames.indexOf(prop) !== -1) { // Property name result += prop; } else { // Property value result += ": " + prop + ";\n"; } } } else { // Short text fragment. result = textArray[0]; } clipboardHelper.copyString(result); } catch (e) { console.error(e); } }, /** * Destructor for CssComputedView. */ destroy: function () { this._viewedElement = null; this._outputParser = null; gDevTools.off("pref-changed", this._handlePrefChange); this._prefObserver.off(PREF_ORIG_SOURCES, this._onSourcePrefChanged); this._prefObserver.destroy(); // Cancel tree construction if (this._createViewsProcess) { this._createViewsProcess.cancel(); } if (this._refreshProcess) { this._refreshProcess.cancel(); } // Remove context menu if (this._contextmenu) { this._contextmenu.destroy(); this._contextmenu = null; } this.tooltips.destroy(); this.highlighters.destroy(); // Remove bound listeners this.styleDocument.removeEventListener("mousedown", this.focusWindow); this.element.removeEventListener("click", this._onClick); this.element.removeEventListener("copy", this._onCopy); this.element.removeEventListener("contextmenu", this._onContextMenu); this.searchField.removeEventListener("input", this._onFilterStyles); this.searchField.removeEventListener("contextmenu", this.inspector.onTextBoxContextMenu); this.searchClearButton.removeEventListener("click", this._onClearSearch); this.includeBrowserStylesCheckbox.removeEventListener("input", this._onIncludeBrowserStyles); // Nodes used in templating this.element = null; this.panel = null; this.searchField = null; this.searchClearButton = null; this.includeBrowserStylesCheckbox = null; // Property views for (let 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 value() { if (this.tree._computed) { let value = this.tree._computed[this.name].value; return value; } return null; } }; /** * A container to give easy access to property data from the template engine. * * @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. */ function PropertyView(tree, name) { this.tree = tree; this.name = name; this.link = "https://developer.mozilla.org/CSS/" + name; this._propertyInfo = new PropertyInfo(tree, name); } PropertyView.prototype = { // The parent element which contains the open attribute element: null, // Property header node propertyHeader: null, // Destination for property names nameNode: null, // Destination for property values valueNode: null, // Are matched rules expanded? matchedExpanded: false, // Matched selector container matchedSelectorsContainer: null, // Matched selector expando matchedExpander: null, // Cache for matched selector views _matchedSelectorViews: null, // The previously selected element used for the selector view caches _prevViewedElement: 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; } let searchTerm = this.tree.searchField.value.toLowerCase(); let isValidSearchTerm = searchTerm.trim().length > 0; if (isValidSearchTerm && this.name.toLowerCase().indexOf(searchTerm) === -1 && this.value.toLowerCase().indexOf(searchTerm) === -1) { return false; } return true; }, /** * Returns the className that should be assigned to the propertyView. * * @return {String} */ get propertyHeaderClassName() { if (this.visible) { let isDark = this.tree._darkStripe = !this.tree._darkStripe; return isDark ? "property-view row-striped" : "property-view"; } return "property-view-hidden"; }, /** * Returns the className that should be assigned to the propertyView content * container. * * @return {String} */ get propertyContentClassName() { if (this.visible) { let isDark = this.tree._darkStripe; return isDark ? "property-content row-striped" : "property-content"; } return "property-content-hidden"; }, /** * Build the markup for on computed style * * @return {Element} */ buildMain: function () { let doc = this.tree.styleDocument; // Build the container element this.onMatchedToggle = this.onMatchedToggle.bind(this); this.element = doc.createElementNS(HTML_NS, "div"); this.element.setAttribute("class", this.propertyHeaderClassName); this.element.addEventListener("dblclick", this.onMatchedToggle, false); // Make it keyboard navigable this.element.setAttribute("tabindex", "0"); this.shortcuts = new KeyShortcuts({ window: this.tree.styleWindow, target: this.element }); this.shortcuts.on("F1", (name, event) => { this.mdnLinkClick(event); // Prevent opening the options panel event.preventDefault(); event.stopPropagation(); }); this.shortcuts.on("Return", (name, event) => this.onMatchedToggle(event)); this.shortcuts.on("Space", (name, event) => this.onMatchedToggle(event)); let nameContainer = doc.createElementNS(HTML_NS, "div"); nameContainer.className = "property-name-container"; this.element.appendChild(nameContainer); // Build the twisty expand/collapse this.matchedExpander = doc.createElementNS(HTML_NS, "div"); this.matchedExpander.className = "expander theme-twisty"; this.matchedExpander.addEventListener("click", this.onMatchedToggle, false); nameContainer.appendChild(this.matchedExpander); // Build the style name element this.nameNode = doc.createElementNS(HTML_NS, "div"); this.nameNode.setAttribute("class", "property-name theme-fg-color5"); // Reset its tabindex attribute otherwise, if an ellipsis is applied // it will be reachable via TABing this.nameNode.setAttribute("tabindex", ""); // Avoid english text (css properties) from being altered // by RTL mode this.nameNode.setAttribute("dir", "ltr"); this.nameNode.textContent = this.nameNode.title = this.name; // Make it hand over the focus to the container this.onFocus = () => this.element.focus(); this.nameNode.addEventListener("click", this.onFocus, false); nameContainer.appendChild(this.nameNode); let valueContainer = doc.createElementNS(HTML_NS, "div"); valueContainer.className = "property-value-container"; this.element.appendChild(valueContainer); // Build the style value element this.valueNode = doc.createElementNS(HTML_NS, "div"); this.valueNode.setAttribute("class", "property-value theme-fg-color1"); // Reset its tabindex attribute otherwise, if an ellipsis is applied // it will be reachable via TABing this.valueNode.setAttribute("tabindex", ""); this.valueNode.setAttribute("dir", "ltr"); // Make it hand over the focus to the container this.valueNode.addEventListener("click", this.onFocus, false); valueContainer.appendChild(this.valueNode); return this.element; }, buildSelectorContainer: function () { let doc = this.tree.styleDocument; let element = doc.createElementNS(HTML_NS, "div"); element.setAttribute("class", this.propertyContentClassName); this.matchedSelectorsContainer = doc.createElementNS(HTML_NS, "div"); this.matchedSelectorsContainer.setAttribute("class", "matchedselectors"); element.appendChild(this.matchedSelectorsContainer); return element; }, /** * Refresh the panel's CSS property value. */ refresh: function () { this.element.className = this.propertyHeaderClassName; this.element.nextElementSibling.className = this.propertyContentClassName; if (this._prevViewedElement !== this.tree._viewedElement) { this._matchedSelectorViews = null; this._prevViewedElement = this.tree._viewedElement; } if (!this.tree._viewedElement || !this.visible) { this.valueNode.textContent = this.valueNode.title = ""; this.matchedSelectorsContainer.parentNode.hidden = true; this.matchedSelectorsContainer.textContent = ""; this.matchedExpander.removeAttribute("open"); return; } this.tree.numVisibleProperties++; let outputParser = this.tree._outputParser; let frag = outputParser.parseCssProperty(this.propertyInfo.name, this.propertyInfo.value, { colorSwatchClass: "computedview-colorswatch", colorClass: "computedview-color", urlClass: "theme-link" // No need to use baseURI here as computed URIs are never relative. }); this.valueNode.innerHTML = ""; this.valueNode.appendChild(frag); this.refreshMatchedSelectors(); }, /** * Refresh the panel matched rules. */ refreshMatchedSelectors: function () { let hasMatchedSelectors = this.hasMatchedSelectors; this.matchedSelectorsContainer.parentNode.hidden = !hasMatchedSelectors; if (hasMatchedSelectors) { this.matchedExpander.classList.add("expandable"); } else { this.matchedExpander.classList.remove("expandable"); } if (this.matchedExpanded && hasMatchedSelectors) { return this.tree.pageStyle .getMatchedSelectors(this.tree._viewedElement, this.name) .then(matched => { if (!this.matchedExpanded) { return promise.resolve(undefined); } this._matchedSelectorResponse = matched; return this._buildMatchedSelectors().then(() => { this.matchedExpander.setAttribute("open", ""); this.tree.inspector.emit("computed-view-property-expanded"); }); }).then(null, console.error); } this.matchedSelectorsContainer.innerHTML = ""; this.matchedExpander.removeAttribute("open"); this.tree.inspector.emit("computed-view-property-collapsed"); return promise.resolve(undefined); }, get matchedSelectors() { return this._matchedSelectorResponse; }, _buildMatchedSelectors: function () { let promises = []; let frag = this.element.ownerDocument.createDocumentFragment(); for (let selector of this.matchedSelectorViews) { let p = createChild(frag, "p"); let span = createChild(p, "span", { class: "rule-link" }); let link = createChild(span, "a", { target: "_blank", class: "link theme-link", title: selector.href, sourcelocation: selector.source, tabindex: "0", textContent: selector.source }); link.addEventListener("click", selector.openStyleEditor, false); let shortcuts = new KeyShortcuts({ window: this.tree.styleWindow, target: link }); shortcuts.on("Return", () => selector.openStyleEditor()); let status = createChild(p, "span", { dir: "ltr", class: "rule-text theme-fg-color3 " + selector.statusClass, title: selector.statusText, textContent: selector.sourceText }); let valueSpan = createChild(status, "span", { class: "other-property-value theme-fg-color1" }); valueSpan.appendChild(selector.outputFragment); promises.push(selector.ready); } this.matchedSelectorsContainer.innerHTML = ""; this.matchedSelectorsContainer.appendChild(frag); return promise.all(promises); }, /** * Provide access to the matched SelectorViews that we are currently * displaying. */ get matchedSelectorViews() { if (!this._matchedSelectorViews) { this._matchedSelectorViews = []; this._matchedSelectorResponse.forEach(selectorInfo => { let selectorView = new SelectorView(this.tree, selectorInfo); this._matchedSelectorViews.push(selectorView); }, this); } return this._matchedSelectorViews; }, /** * Update all the selector source links to reflect whether we're linking to * original sources (e.g. Sass files). */ updateSourceLinks: function () { if (!this._matchedSelectorViews) { return; } for (let view of this._matchedSelectorViews) { view.updateSourceLink(); } }, /** * The action when a user expands matched selectors. * * @param {Event} event * Used to determine the class name of the targets click * event. */ onMatchedToggle: function (event) { if (event.shiftKey) { return; } this.matchedExpanded = !this.matchedExpanded; this.refreshMatchedSelectors(); event.preventDefault(); }, /** * The action when a user clicks on the MDN help link for a property. */ mdnLinkClick: function (event) { let inspector = this.tree.inspector; if (inspector.target.tab) { let browserWin = inspector.target.tab.ownerDocument.defaultView; browserWin.openUILinkIn(this.link, "tab"); } }, /** * Destroy this property view, removing event listeners */ destroy: function () { this.element.removeEventListener("dblclick", this.onMatchedToggle, false); this.shortcuts.destroy(); this.element = null; this.matchedExpander.removeEventListener("click", this.onMatchedToggle, false); this.matchedExpander = null; this.nameNode.removeEventListener("click", this.onFocus, false); this.nameNode = null; this.valueNode.removeEventListener("click", this.onFocus, false); this.valueNode = null; } }; /** * A container to give us easy access to display data from a CssRule * * @param CssComputedView tree * the owning CssComputedView * @param selectorInfo */ function SelectorView(tree, selectorInfo) { this.tree = tree; this.selectorInfo = selectorInfo; this._cacheStatusNames(); this.openStyleEditor = this.openStyleEditor.bind(this); this.ready = this.updateSourceLink(); } /** * Decode for cssInfo.rule.status * @see SelectorView.prototype._cacheStatusNames * @see CssLogic.STATUS */ SelectorView.STATUS_NAMES = [ // "Parent Match", "Matched", "Best Match" ]; SelectorView.CLASS_NAMES = [ "parentmatch", "matched", "bestmatch" ]; SelectorView.prototype = { /** * Cache localized status names. * * These statuses are localized inside the styleinspector.properties string * bundle. * @see css-logic.js - the CssLogic.STATUS array. */ _cacheStatusNames: function () { if (SelectorView.STATUS_NAMES.length) { return; } for (let status in CssLogic.STATUS) { let i = CssLogic.STATUS[status]; if (i > CssLogic.STATUS.UNMATCHED) { let value = CssComputedView.l10n("rule.status." + status); // Replace normal spaces with non-breaking spaces SelectorView.STATUS_NAMES[i] = value.replace(/ /g, "\u00A0"); } } }, /** * A localized version of cssRule.status */ get statusText() { return SelectorView.STATUS_NAMES[this.selectorInfo.status]; }, /** * Get class name for selector depending on status */ get statusClass() { return SelectorView.CLASS_NAMES[this.selectorInfo.status - 1]; }, get href() { if (this._href) { return this._href; } let sheet = this.selectorInfo.rule.parentStyleSheet; this._href = sheet ? sheet.href : "#"; return this._href; }, get sourceText() { return this.selectorInfo.sourceText; }, get value() { return this.selectorInfo.value; }, get outputFragment() { // Sadly, because this fragment is added to the template by DOM Templater // we lose any events that are attached. This means that URLs will open in a // new window. At some point we should fix this by stopping using the // templater. let outputParser = this.tree._outputParser; let frag = outputParser.parseCssProperty( this.selectorInfo.name, this.selectorInfo.value, { colorSwatchClass: "computedview-colorswatch", colorClass: "computedview-color", urlClass: "theme-link", baseURI: this.selectorInfo.rule.href } ); return frag; }, /** * Update the text of the source link to reflect whether we're showing * original sources or not. */ updateSourceLink: function () { return this.updateSource().then((oldSource) => { if (oldSource !== this.source && this.tree.element) { let selector = '[sourcelocation="' + oldSource + '"]'; let link = this.tree.element.querySelector(selector); if (link) { link.textContent = this.source; link.setAttribute("sourcelocation", this.source); } } }); }, /** * Update the 'source' store based on our original sources preference. */ updateSource: function () { let rule = this.selectorInfo.rule; this.sheet = rule.parentStyleSheet; if (!rule || !this.sheet) { let oldSource = this.source; this.source = CssLogic.l10n("rule.sourceElement"); return promise.resolve(oldSource); } let showOrig = Services.prefs.getBoolPref(PREF_ORIG_SOURCES); if (showOrig && rule.type !== ELEMENT_STYLE) { let deferred = defer(); // set as this first so we show something while we're fetching this.source = CssLogic.shortSource(this.sheet) + ":" + rule.line; rule.getOriginalLocation().then(({href, line}) => { let oldSource = this.source; this.source = CssLogic.shortSource({href: href}) + ":" + line; deferred.resolve(oldSource); }); return deferred.promise; } let oldSource = this.source; this.source = CssLogic.shortSource(this.sheet) + ":" + rule.line; return promise.resolve(oldSource); }, /** * When a css link is clicked this method is called in order to either: * 1. Open the link in view source (for chrome stylesheets). * 2. Open the link in the style editor. * * We can only view stylesheets contained in document.styleSheets inside the * style editor. */ openStyleEditor: function () { let inspector = this.tree.inspector; let rule = this.selectorInfo.rule; // The style editor can only display stylesheets coming from content because // chrome stylesheets are not listed in the editor's stylesheet selector. // // If the stylesheet is a content stylesheet we send it to the style // editor else we display it in the view source window. let parentStyleSheet = rule.parentStyleSheet; if (!parentStyleSheet || parentStyleSheet.isSystem) { let toolbox = gDevTools.getToolbox(inspector.target); toolbox.viewSource(rule.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 = inspector.target; if (ToolDefinitions.styleEditor.isTargetSupported(target)) { gDevTools.showToolbox(target, "styleeditor").then(function (toolbox) { let sheet = source || href; toolbox.getCurrentPanel().selectStyleSheet(sheet, line, column); }); } }); } }; function ComputedViewTool(inspector, window) { this.inspector = inspector; this.document = window.document; this.computedView = new CssComputedView(this.inspector, this.document, this.inspector.pageStyle); this.boxModelView = new BoxModelView(this.inspector, this.document); this.onSelected = this.onSelected.bind(this); this.refresh = this.refresh.bind(this); this.onPanelSelected = this.onPanelSelected.bind(this); this.onMutations = this.onMutations.bind(this); this.onResized = this.onResized.bind(this); 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.sidebar.on("computedview-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.computedView.selectElement(null); this.onSelected(); } ComputedViewTool.prototype = { isSidebarActive: function () { if (!this.computedView) { return false; } return this.inspector.sidebar.getCurrentTabID() == "computedview"; }, 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.computedView) { return; } let isInactive = !this.isSidebarActive() && this.inspector.selection.nodeFront; if (isInactive) { return; } this.computedView.setPageStyle(this.inspector.pageStyle); if (!this.inspector.selection.isConnected() || !this.inspector.selection.isElementNode()) { this.computedView.selectElement(null); return; } if (!event || event == "new-node-front") { let done = this.inspector.updating("computed-view"); this.computedView.selectElement(this.inspector.selection.nodeFront).then(() => { done(); }); } }, refresh: function () { if (this.isSidebarActive()) { this.computedView.refreshPanel(); } }, onPanelSelected: function () { if (this.inspector.selection.nodeFront === this.computedView._viewedElement) { this.refresh(); } else { this.onSelected(); } }, /** * 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.sidebar.off("computedview-selected", this.refresh); this.inspector.selection.off("pseudoclass", this.refresh); this.inspector.selection.off("new-node-front", this.onSelected); this.inspector.selection.off("detached-front", this.onSelected); this.inspector.sidebar.off("computedview-selected", this.onPanelSelected); if (this.inspector.pageStyle) { this.inspector.pageStyle.off("stylesheet-updated", this.refresh); } this.computedView.destroy(); this.boxModelView.destroy(); this.computedView = this.boxModelView = this.document = this.inspector = null; } }; exports.CssComputedView = CssComputedView; exports.ComputedViewTool = ComputedViewTool; exports.PropertyView = PropertyView;