diff -r 000000000000 -r 6474c204b198 browser/devtools/styleinspector/computed-view.js --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/browser/devtools/styleinspector/computed-view.js Wed Dec 31 06:09:35 2014 +0100 @@ -0,0 +1,1388 @@ +/* -*- Mode: Javascript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 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/. */ + +const {Cc, Ci, Cu} = require("chrome"); + +const ToolDefinitions = require("main").Tools; +const {CssLogic} = require("devtools/styleinspector/css-logic"); +const {ELEMENT_STYLE} = require("devtools/server/actors/styles"); +const {Promise: promise} = Cu.import("resource://gre/modules/Promise.jsm", {}); +const {EventEmitter} = require("devtools/toolkit/event-emitter"); +const {OutputParser} = require("devtools/output-parser"); +const {Tooltip} = require("devtools/shared/widgets/Tooltip"); +const {PrefObserver, PREF_ORIG_SOURCES} = require("devtools/styleeditor/utils"); +const {gDevTools} = Cu.import("resource:///modules/devtools/gDevTools.jsm", {}); + +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/devtools/Templater.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "PluralForm", + "resource://gre/modules/PluralForm.jsm"); + +const FILTER_CHANGED_TIMEOUT = 300; +const HTML_NS = "http://www.w3.org/1999/xhtml"; +const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; + +/** + * Helper for long-running processes that should yield occasionally to + * the mainloop. + * + * @param {Window} aWin + * Timeouts will be set on this window when appropriate. + * @param {Generator} aGenerator + * Will iterate this generator. + * @param {object} aOptions + * Options for the update process: + * onItem {function} Will be called with the value of each iteration. + * onBatch {function} Will be called after each batch of iterations, + * before yielding to the main loop. + * onDone {function} Will be called when iteration is complete. + * onCancel {function} Will be called if the process is canceled. + * threshold {int} How long to process before yielding, in ms. + * + * @constructor + */ +function UpdateProcess(aWin, aGenerator, aOptions) +{ + this.win = aWin; + this.iter = _Iterator(aGenerator); + this.onItem = aOptions.onItem || function() {}; + this.onBatch = aOptions.onBatch || function () {}; + this.onDone = aOptions.onDone || function() {}; + this.onCancel = aOptions.onCancel || function() {}; + this.threshold = aOptions.threshold || 45; + + this.canceled = false; +} + +UpdateProcess.prototype = { + /** + * Schedule a new batch on the main loop. + */ + schedule: function UP_schedule() + { + if (this.canceled) { + return; + } + this._timeout = this.win.setTimeout(this._timeoutHandler.bind(this), 0); + }, + + /** + * Cancel the running process. onItem will not be called again, + * and onCancel will be called. + */ + cancel: function UP_cancel() + { + if (this._timeout) { + this.win.clearTimeout(this._timeout); + this._timeout = 0; + } + this.canceled = true; + this.onCancel(); + }, + + _timeoutHandler: function UP_timeoutHandler() { + this._timeout = null; + try { + this._runBatch(); + this.schedule(); + } catch(e) { + if (e instanceof StopIteration) { + this.onBatch(); + this.onDone(); + return; + } + console.error(e); + throw e; + } + }, + + _runBatch: function Y_runBatch() + { + let time = Date.now(); + while(!this.canceled) { + // Continue until iter.next() throws... + let next = this.iter.next(); + this.onItem(next[1]); + if ((Date.now() - time) > this.threshold) { + this.onBatch(); + return; + } + } + } +}; + +/** + * CssHtmlTree is a panel that manages the display of a table sorted by style. + * There should be one instance of CssHtmlTree per style display (of which there + * will generally only be one). + * + * @params {StyleInspector} aStyleInspector The owner of this CssHtmlTree + * @param {PageStyleFront} aPageStyle + * Front for the page style actor that will be providing + * the style information. + * + * @constructor + */ +function CssHtmlTree(aStyleInspector, aPageStyle) +{ + this.styleWindow = aStyleInspector.window; + this.styleDocument = aStyleInspector.window.document; + this.styleInspector = aStyleInspector; + this.pageStyle = aPageStyle; + this.propertyViews = []; + + this._outputParser = new OutputParser(); + + let chromeReg = Cc["@mozilla.org/chrome/chrome-registry;1"]. + getService(Ci.nsIXULChromeRegistry); + this.getRTLAttr = chromeReg.isLocaleRTL("global") ? "rtl" : "ltr"; + + // Create bound methods. + this.focusWindow = this.focusWindow.bind(this); + this._onContextMenu = this._onContextMenu.bind(this); + this._contextMenuUpdate = this._contextMenuUpdate.bind(this); + this._onSelectAll = this._onSelectAll.bind(this); + this._onClick = this._onClick.bind(this); + this._onCopy = this._onCopy.bind(this); + + this.styleDocument.addEventListener("copy", this._onCopy); + this.styleDocument.addEventListener("mousedown", this.focusWindow); + this.styleDocument.addEventListener("contextmenu", this._onContextMenu); + + // Nodes used in templating + this.root = this.styleDocument.getElementById("root"); + this.templateRoot = this.styleDocument.getElementById("templateRoot"); + this.propertyContainer = this.styleDocument.getElementById("propertyContainer"); + + // Listen for click events + this.propertyContainer.addEventListener("click", this._onClick, false); + + // No results text. + this.noResults = this.styleDocument.getElementById("noResults"); + + // 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._updateSourceLinks = this._updateSourceLinks.bind(this); + this._prefObserver = new PrefObserver("devtools."); + this._prefObserver.on(PREF_ORIG_SOURCES, this._updateSourceLinks); + + CssHtmlTree.processTemplate(this.templateRoot, this.root, this); + + // The element that we're inspecting, and the document that it comes from. + this.viewedElement = null; + + // Properties preview tooltip + this.tooltip = new Tooltip(this.styleInspector.inspector.panelDoc); + this.tooltip.startTogglingOnHover(this.propertyContainer, + this._onTooltipTargetHover.bind(this)); + + this._buildContextMenu(); + this.createStyleViews(); +} + +/** + * Memoized lookup of a l10n string from a string bundle. + * @param {string} aName The key to lookup. + * @returns A localized version of the given key. + */ +CssHtmlTree.l10n = function CssHtmlTree_l10n(aName) +{ + try { + return CssHtmlTree._strings.GetStringFromName(aName); + } catch (ex) { + Services.console.logStringMessage("Error reading '" + aName + "'"); + throw new Error("l10n error with " + aName); + } +}; + +/** + * Clone the given template node, and process it by resolving ${} references + * in the template. + * + * @param {nsIDOMElement} aTemplate the template note to use. + * @param {nsIDOMElement} aDestination the destination node where the + * processed nodes will be displayed. + * @param {object} aData the data to pass to the template. + * @param {Boolean} aPreserveDestination If true then the template will be + * appended to aDestination's content else aDestination.innerHTML will be + * cleared before the template is appended. + */ +CssHtmlTree.processTemplate = function CssHtmlTree_processTemplate(aTemplate, + aDestination, aData, aPreserveDestination) +{ + if (!aPreserveDestination) { + aDestination.innerHTML = ""; + } + + // All the templater does is to populate a given DOM tree with the given + // values, so we need to clone the template first. + let duplicated = aTemplate.cloneNode(true); + + // See https://github.com/mozilla/domtemplate/blob/master/README.md + // for docs on the template() function + template(duplicated, aData, { allowEval: true }); + while (duplicated.firstChild) { + aDestination.appendChild(duplicated.firstChild); + } +}; + +XPCOMUtils.defineLazyGetter(CssHtmlTree, "_strings", function() Services.strings + .createBundle("chrome://global/locale/devtools/styleinspector.properties")); + +XPCOMUtils.defineLazyGetter(this, "clipboardHelper", function() { + return Cc["@mozilla.org/widget/clipboardhelper;1"]. + getService(Ci.nsIClipboardHelper); +}); + +CssHtmlTree.prototype = { + // Cache the list of properties that match the selected element. + _matchedProperties: null, + + // Used for cancelling timeouts in the style filter. + _filterChangedTimeout: null, + + // The search filter + searchField: null, + + // Reference to the "Include browser styles" checkbox. + includeBrowserStylesCheckbox: 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 highlighted element. The CssHtmlTree panel will show the style + * information for the given element. + * @param {nsIDOMElement} aElement The highlighted node to get styles for. + * + * @returns a promise that will be resolved when highlighting is complete. + */ + highlight: function(aElement) { + if (!aElement) { + 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); + } + + this.tooltip.hide(); + + if (aElement === this.viewedElement) { + return promise.resolve(undefined); + } + + this.viewedElement = aElement; + this.refreshSourceFilter(); + + return this.refreshPanel(); + }, + + _createPropertyViews: function() + { + if (this._createViewsPromise) { + return this._createViewsPromise; + } + + let deferred = promise.defer(); + this._createViewsPromise = deferred.promise; + + this.refreshSourceFilter(); + this.numVisibleProperties = 0; + let fragment = this.styleDocument.createDocumentFragment(); + + this._createViewsProcess = new UpdateProcess(this.styleWindow, CssHtmlTree.propertyNames, { + onItem: (aPropertyName) => { + // Per-item callback. + let propView = new PropertyView(this, aPropertyName); + 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.propertyContainer.appendChild(fragment); + this.noResults.hidden = this.numVisibleProperties > 0; + deferred.resolve(undefined); + } + }); + + this._createViewsProcess.schedule(); + return deferred.promise; + }, + + /** + * Refresh the panel content. + */ + refreshPanel: function CssHtmlTree_refreshPanel() + { + if (!this.viewedElement) { + return promise.resolve(); + } + + return promise.all([ + this._createPropertyViews(), + this.pageStyle.getComputed(this.viewedElement, { + filter: this._sourceFilter, + onlyMatched: !this.includeBrowserStyles, + markMatched: true + }) + ]).then(([createViews, computed]) => { + 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 = promise.defer(); + this._refreshProcess = new UpdateProcess(this.styleWindow, this.propertyViews, { + onItem: (aPropView) => { + aPropView.refresh(); + }, + onDone: () => { + this._refreshProcess = null; + this.noResults.hidden = this.numVisibleProperties > 0; + this.styleInspector.inspector.emit("computed-view-refreshed"); + deferred.resolve(undefined); + } + }); + this._refreshProcess.schedule(); + return deferred.promise; + }).then(null, (err) => console.error(err)); + }, + + /** + * Called when the user enters a search term. + * + * @param {Event} aEvent the DOM Event object. + */ + filterChanged: function CssHtmlTree_filterChanged(aEvent) + { + let win = this.styleWindow; + + if (this._filterChangedTimeout) { + win.clearTimeout(this._filterChangedTimeout); + } + + this._filterChangedTimeout = win.setTimeout(function() { + this.refreshPanel(); + this._filterChangeTimeout = null; + }.bind(this), FILTER_CHANGED_TIMEOUT); + }, + + /** + * The change event handler for the includeBrowserStyles checkbox. + * + * @param {Event} aEvent the DOM Event object. + */ + includeBrowserStylesChanged: + function CssHtmltree_includeBrowserStylesChanged(aEvent) + { + this.refreshSourceFilter(); + this.refreshPanel(); + }, + + /** + * When includeBrowserStyles.checked is false we only display properties that + * have matched selectors and have been included by the document or one of the + * document's stylesheets. If .checked is false we display all properties + * including those that come from UA stylesheets. + */ + refreshSourceFilter: function CssHtmlTree_setSourceFilter() + { + this._matchedProperties = null; + this._sourceFilter = this.includeBrowserStyles ? + CssLogic.FILTER.UA : + CssLogic.FILTER.USER; + }, + + _updateSourceLinks: function CssHtmlTree__updateSourceLinks() + { + for (let propView of this.propertyViews) { + propView.updateSourceLinks(); + } + }, + + /** + * The CSS as displayed by the UI. + */ + createStyleViews: function CssHtmlTree_createStyleViews() + { + if (CssHtmlTree.propertyNames) { + return; + } + + CssHtmlTree.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.charAt(0) == "-") { + mozProps.push(prop); + } else { + CssHtmlTree.propertyNames.push(prop); + } + } + + CssHtmlTree.propertyNames.sort(); + CssHtmlTree.propertyNames.push.apply(CssHtmlTree.propertyNames, + mozProps.sort()); + + this._createPropertyViews(); + }, + + /** + * 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. + * + * @param aEvent The event object + */ + focusWindow: function(aEvent) + { + let win = this.styleDocument.defaultView; + win.focus(); + }, + + /** + * Executed by the tooltip when the pointer hovers over an element of the view. + * Used to decide whether the tooltip should be shown or not and to actually + * put content in it. + * Checks if the hovered target is a css value we support tooltips for. + */ + _onTooltipTargetHover: function(target) + { + let inspector = this.styleInspector.inspector; + + // Test for image url + if (target.classList.contains("theme-link") && inspector.hasUrlToImageDataResolver) { + let propValue = target.parentNode; + let propName = propValue.parentNode.querySelector(".property-name"); + if (propName.textContent === "background-image") { + let maxDim = Services.prefs.getIntPref("devtools.inspector.imagePreviewTooltipSize"); + let uri = CssLogic.getBackgroundImageUriFromProperty(propValue.textContent); + return this.tooltip.setRelativeImageContent(uri, inspector.inspector, maxDim); + } + } + + if (target.classList.contains("property-value")) { + let propValue = target; + let propName = target.parentNode.querySelector(".property-name"); + + // Test for css transform + if (propName.textContent === "transform") { + return this.tooltip.setCssTransformContent(propValue.textContent, + this.pageStyle, this.viewedElement); + } + + // Test for font family + if (propName.textContent === "font-family") { + this.tooltip.setFontFamilyContent(propValue.textContent); + return true; + } + } + + // If the target isn't one that should receive a tooltip, signal it by rejecting + // a promise + return promise.reject(); + }, + + /** + * Create a context menu. + */ + _buildContextMenu: function() + { + let doc = this.styleDocument.defaultView.parent.document; + + this._contextmenu = this.styleDocument.createElementNS(XUL_NS, "menupopup"); + this._contextmenu.addEventListener("popupshowing", this._contextMenuUpdate); + this._contextmenu.id = "computed-view-context-menu"; + + // Select All + this.menuitemSelectAll = createMenuItem(this._contextmenu, { + label: "computedView.contextmenu.selectAll", + accesskey: "computedView.contextmenu.selectAll.accessKey", + command: this._onSelectAll + }); + + // Copy + this.menuitemCopy = createMenuItem(this._contextmenu, { + label: "computedView.contextmenu.copy", + accesskey: "computedView.contextmenu.copy.accessKey", + command: this._onCopy + }); + + // Show Original Sources + this.menuitemSources= createMenuItem(this._contextmenu, { + label: "ruleView.contextmenu.showOrigSources", + accesskey: "ruleView.contextmenu.showOrigSources.accessKey", + command: this._onToggleOrigSources + }); + + let popupset = doc.documentElement.querySelector("popupset"); + if (!popupset) { + popupset = doc.createElementNS(XUL_NS, "popupset"); + doc.documentElement.appendChild(popupset); + } + popupset.appendChild(this._contextmenu); + }, + + /** + * Update the context menu. This means enabling or disabling menuitems as + * appropriate. + */ + _contextMenuUpdate: function() + { + let win = this.styleDocument.defaultView; + let disable = win.getSelection().isCollapsed; + this.menuitemCopy.disabled = disable; + + let label = "ruleView.contextmenu.showOrigSources"; + if (Services.prefs.getBoolPref(PREF_ORIG_SOURCES)) { + label = "ruleView.contextmenu.showCSSSources"; + } + this.menuitemSources.setAttribute("label", + CssHtmlTree.l10n(label)); + + let accessKey = label + ".accessKey"; + this.menuitemSources.setAttribute("accesskey", + CssHtmlTree.l10n(accessKey)); + }, + + /** + * Context menu handler. + */ + _onContextMenu: function(event) { + try { + this.styleDocument.defaultView.focus(); + this._contextmenu.openPopupAtScreen(event.screenX, event.screenY, true); + } catch(e) { + console.error(e); + } + }, + + /** + * Select all text. + */ + _onSelectAll: function() + { + try { + let win = this.styleDocument.defaultView; + let selection = win.getSelection(); + + selection.selectAllChildren(this.styleDocument.documentElement); + } catch(e) { + console.error(e); + } + }, + + _onClick: function(event) { + let target = event.target; + + if (target.nodeName === "a") { + event.stopPropagation(); + event.preventDefault(); + let browserWin = this.styleInspector.inspector.target + .tab.ownerDocument.defaultView; + browserWin.openUILinkIn(target.href, "tab"); + } + }, + + /** + * Copy selected text. + * + * @param event The event object + */ + _onCopy: function(event) + { + try { + let win = this.styleDocument.defaultView; + 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 (CssHtmlTree.propertyNames.indexOf(prop) !== -1) { + // Property name + result += prop; + } else { + // Property value + result += ": " + prop; + if (result.length > 0) { + result += ";\n"; + } + } + } + } else { + // Short text fragment. + result = textArray[0]; + } + + clipboardHelper.copyString(result, this.styleDocument); + + if (event) { + event.preventDefault(); + } + } catch(e) { + console.error(e); + } + }, + + /** + * Toggle the original sources pref. + */ + _onToggleOrigSources: function() + { + let isEnabled = Services.prefs.getBoolPref(PREF_ORIG_SOURCES); + Services.prefs.setBoolPref(PREF_ORIG_SOURCES, !isEnabled); + }, + + /** + * Destructor for CssHtmlTree. + */ + destroy: function CssHtmlTree_destroy() + { + delete this.viewedElement; + delete this._outputParser; + + // Remove event listeners + this.includeBrowserStylesCheckbox.removeEventListener("command", + this.includeBrowserStylesChanged); + this.searchField.removeEventListener("command", this.filterChanged); + gDevTools.off("pref-changed", this._handlePrefChange); + + this._prefObserver.off(PREF_ORIG_SOURCES, this._updateSourceLinks); + this._prefObserver.destroy(); + + // Cancel tree construction + if (this._createViewsProcess) { + this._createViewsProcess.cancel(); + } + if (this._refreshProcess) { + this._refreshProcess.cancel(); + } + + this.propertyContainer.removeEventListener("click", this._onClick, false); + + // Remove context menu + if (this._contextmenu) { + // Destroy the Select All menuitem. + this.menuitemCopy.removeEventListener("command", this._onCopy); + this.menuitemCopy = null; + + // Destroy the Copy menuitem. + this.menuitemSelectAll.removeEventListener("command", this._onSelectAll); + this.menuitemSelectAll = null; + + // Destroy the context menu. + this._contextmenu.removeEventListener("popupshowing", this._contextMenuUpdate); + this._contextmenu.parentNode.removeChild(this._contextmenu); + this._contextmenu = null; + } + + this.tooltip.stopTogglingOnHover(this.propertyContainer); + this.tooltip.destroy(); + + // Remove bound listeners + this.styleDocument.removeEventListener("contextmenu", this._onContextMenu); + this.styleDocument.removeEventListener("copy", this._onCopy); + this.styleDocument.removeEventListener("mousedown", this.focusWindow); + + // Nodes used in templating + delete this.root; + delete this.propertyContainer; + delete this.panel; + + // The document in which we display the results (csshtmltree.xul). + delete this.styleDocument; + + for (let propView of this.propertyViews) { + propView.destroy(); + } + + // The element that we're inspecting, and the document that it comes from. + delete this.propertyViews; + delete this.styleWindow; + delete this.styleDocument; + delete this.styleInspector; + } +}; + +function PropertyInfo(aTree, aName) { + this.tree = aTree; + this.name = aName; +} +PropertyInfo.prototype = { + get value() { + if (this.tree._computed) { + let value = this.tree._computed[this.name].value; + return value; + } + } +}; + +function createMenuItem(aMenu, aAttributes) +{ + let item = aMenu.ownerDocument.createElementNS(XUL_NS, "menuitem"); + + item.setAttribute("label", CssHtmlTree.l10n(aAttributes.label)); + item.setAttribute("accesskey", CssHtmlTree.l10n(aAttributes.accesskey)); + item.addEventListener("command", aAttributes.command); + + aMenu.appendChild(item); + + return item; +} + +/** + * A container to give easy access to property data from the template engine. + * + * @constructor + * @param {CssHtmlTree} aTree the CssHtmlTree instance we are working with. + * @param {string} aName the CSS property name for which this PropertyView + * instance will render the rules. + */ +function PropertyView(aTree, aName) +{ + this.tree = aTree; + this.name = aName; + this.getRTLAttr = aTree.getRTLAttr; + + this.link = "https://developer.mozilla.org/CSS/" + aName; + + this.templateMatchedSelectors = aTree.styleDocument.getElementById("templateMatchedSelectors"); + this._propertyInfo = new PropertyInfo(aTree, aName); +} + +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(); + if (searchTerm && 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 theme-bg-darker" : "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 theme-bg-darker" : "property-content"; + } + return "property-content-hidden"; + }, + + /** + * Build the markup for on computed style + * @return Element + */ + buildMain: function PropertyView_buildMain() + { + 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.onKeyDown = (aEvent) => { + let keyEvent = Ci.nsIDOMKeyEvent; + if (aEvent.keyCode == keyEvent.DOM_VK_F1) { + this.mdnLinkClick(); + } + if (aEvent.keyCode == keyEvent.DOM_VK_RETURN || + aEvent.keyCode == keyEvent.DOM_VK_SPACE) { + this.onMatchedToggle(aEvent); + } + }; + this.element.addEventListener("keydown", this.onKeyDown, false); + + // 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); + this.element.appendChild(this.matchedExpander); + + this.focusElement = () => this.element.focus(); + + // 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", ""); + 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); + this.element.appendChild(this.nameNode); + + // 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); + this.element.appendChild(this.valueNode); + + return this.element; + }, + + buildSelectorContainer: function PropertyView_buildSelectorContainer() + { + 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 PropertyView_refresh() + { + 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", + 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 PropertyView_refreshMatchedSelectors() + { + 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; + } + + this._matchedSelectorResponse = matched; + CssHtmlTree.processTemplate(this.templateMatchedSelectors, + this.matchedSelectorsContainer, this); + this.matchedExpander.setAttribute("open", ""); + this.tree.styleInspector.inspector.emit("computed-view-property-expanded"); + }).then(null, console.error); + } else { + this.matchedSelectorsContainer.innerHTML = ""; + this.matchedExpander.removeAttribute("open"); + this.tree.styleInspector.inspector.emit("computed-view-property-collapsed"); + return promise.resolve(undefined); + } + }, + + get matchedSelectors() + { + return this._matchedSelectorResponse; + }, + + /** + * Provide access to the matched SelectorViews that we are currently + * displaying. + */ + get matchedSelectorViews() + { + if (!this._matchedSelectorViews) { + this._matchedSelectorViews = []; + this._matchedSelectorResponse.forEach( + function matchedSelectorViews_convert(aSelectorInfo) { + this._matchedSelectorViews.push(new SelectorView(this.tree, aSelectorInfo)); + }, 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 PropertyView_updateSourceLinks() + { + if (!this._matchedSelectorViews) { + return; + } + for (let view of this._matchedSelectorViews) { + view.updateSourceLink(); + } + }, + + /** + * The action when a user expands matched selectors. + * + * @param {Event} aEvent Used to determine the class name of the targets click + * event. + */ + onMatchedToggle: function PropertyView_onMatchedToggle(aEvent) + { + this.matchedExpanded = !this.matchedExpanded; + this.refreshMatchedSelectors(); + aEvent.preventDefault(); + }, + + /** + * The action when a user clicks on the MDN help link for a property. + */ + mdnLinkClick: function PropertyView_mdnLinkClick(aEvent) + { + let inspector = this.tree.styleInspector.inspector; + + if (inspector.target.tab) { + let browserWin = inspector.target.tab.ownerDocument.defaultView; + browserWin.openUILinkIn(this.link, "tab"); + } + aEvent.preventDefault(); + }, + + /** + * Destroy this property view, removing event listeners + */ + destroy: function PropertyView_destroy() { + this.element.removeEventListener("dblclick", this.onMatchedToggle, false); + this.element.removeEventListener("keydown", this.onKeyDown, false); + 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 CssHtmlTree aTree, the owning CssHtmlTree + * @param aSelectorInfo + */ +function SelectorView(aTree, aSelectorInfo) +{ + this.tree = aTree; + this.selectorInfo = aSelectorInfo; + this._cacheStatusNames(); + + 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. + * + * @return {void} + */ + _cacheStatusNames: function SelectorView_cacheStatusNames() + { + if (SelectorView.STATUS_NAMES.length) { + return; + } + + for (let status in CssLogic.STATUS) { + let i = CssLogic.STATUS[status]; + if (i > CssLogic.STATUS.UNMATCHED) { + let value = CssHtmlTree.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", + 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() + { + this.updateSource().then((oldSource) => { + if (oldSource != this.source && this.tree.propertyContainer) { + let selector = '[sourcelocation="' + oldSource + '"]'; + let link = this.tree.propertyContainer.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"); + this.href = "#"; + return promise.resolve(oldSource); + } + + let showOrig = Services.prefs.getBoolPref(PREF_ORIG_SOURCES); + + if (showOrig && rule.type != ELEMENT_STYLE) { + let deferred = promise.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, column}) => { + 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); + }, + + /** + * Open the style editor if the RETURN key was pressed. + */ + maybeOpenStyleEditor: function(aEvent) + { + let keyEvent = Ci.nsIDOMKeyEvent; + if (aEvent.keyCode == keyEvent.DOM_VK_RETURN) { + this.openStyleEditor(); + } + }, + + /** + * 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. + * + * @param aEvent The click event + */ + openStyleEditor: function(aEvent) + { + let inspector = this.tree.styleInspector.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 sheet = rule.parentStyleSheet; + if (!sheet || sheet.isSystem) { + let contentDoc = null; + if (this.tree.viewedElement.isLocal_toBeDeprecated()) { + let rawNode = this.tree.viewedElement.rawNode(); + if (rawNode) { + contentDoc = rawNode.ownerDocument; + } + } + let viewSourceUtils = inspector.viewSourceUtils; + viewSourceUtils.viewSource(rule.href, null, contentDoc, rule.line); + return; + } + + let location = promise.resolve({ + href: rule.href, + line: rule.line + }); + if (rule.href && Services.prefs.getBoolPref(PREF_ORIG_SOURCES)) { + location = rule.getOriginalLocation(); + } + + location.then(({href, line}) => { + let target = inspector.target; + if (ToolDefinitions.styleEditor.isTargetSupported(target)) { + gDevTools.showToolbox(target, "styleeditor").then(function(toolbox) { + toolbox.getCurrentPanel().selectStyleSheet(href, line); + }); + } + }); + } +}; + +exports.CssHtmlTree = CssHtmlTree; +exports.PropertyView = PropertyView;