tenfourfox/devtools/client/styleinspector/computed-view.js
Cameron Kaiser c9b2922b70 hello FPR
2017-04-19 00:56:45 -07:00

1411 lines
42 KiB
JavaScript

/* -*- 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/. */
/* globals _Iterator, StopIteration */
"use strict";
const {Cc, Ci, Cu} = require("chrome");
const ToolDefinitions = require("devtools/client/main").Tools;
const {CssLogic} = require("devtools/shared/styleinspector/css-logic");
const {ELEMENT_STYLE} = require("devtools/server/actors/styles");
const promise = require("promise");
const {setTimeout, clearTimeout} = Cu.import("resource://gre/modules/Timer.jsm", {});
const {OutputParser} = require("devtools/client/shared/output-parser");
const {PrefObserver, PREF_ORIG_SOURCES} = require("devtools/client/styleeditor/utils");
const {createChild} = require("devtools/client/styleinspector/utils");
const {gDevTools} = Cu.import("resource://devtools/client/framework/gDevTools.jsm", {});
loader.lazyRequireGetter(this, "overlays",
"devtools/client/styleinspector/style-inspector-overlays");
loader.lazyRequireGetter(this, "StyleInspectorMenu",
"devtools/client/styleinspector/style-inspector-menu");
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "PluralForm",
"resource://gre/modules/PluralForm.jsm");
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 {Generator} generator
* Will iterate this generator.
* @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, generator, options) {
this.win = win;
this.iter = _Iterator(generator);
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 = {
/**
* 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 instanceof StopIteration) {
this.onBatch();
this.onDone();
return;
}
console.error(e);
throw e;
}
},
_runBatch: function() {
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;
}
}
}
};
/**
* 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 = [];
this._outputParser = new OutputParser(document);
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._onKeypress = this._onKeypress.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._onFilterKeyPress = this._onFilterKeyPress.bind(this);
this._onClearSearch = this._onClearSearch.bind(this);
this._onIncludeBrowserStyles = this._onIncludeBrowserStyles.bind(this);
this._onFilterTextboxContextMenu =
this._onFilterTextboxContextMenu.bind(this);
let doc = this.styleDocument;
this.root = doc.getElementById("root");
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.styleDocument.addEventListener("keypress", this._onKeypress);
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("keypress", this._onFilterKeyPress);
this.searchField.addEventListener("contextmenu",
this._onFilterTextboxContextMenu);
this.searchClearButton.addEventListener("click", this._onClearSearch);
this.includeBrowserStylesCheckbox.addEventListener("command",
this._onIncludeBrowserStyles);
this.searchClearButton.hidden = true;
// 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._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 overlays.TooltipsOverlay(this);
this.tooltips.addToView();
this.highlighters = new overlays.HighlightersOverlay(this);
this.highlighters.addToView();
}
/**
* Memoized lookup of a l10n string from a string bundle.
*
* @param {String} name
* The key to lookup.
* @returns {String} localized version of the given key.
*/
CssComputedView.l10n = function(name) {
try {
return CssComputedView._strings.GetStringFromName(name);
} catch (ex) {
Services.console.logStringMessage("Error reading '" + name + "'");
throw new Error("l10n error with " + name);
}
};
XPCOMUtils.defineLazyGetter(CssComputedView, "_strings", function() {
return Services.strings.createBundle(
"chrome://devtools-shared/locale/styleinspector.properties");
});
XPCOMUtils.defineLazyGetter(this, "clipboardHelper", function() {
return Cc["@mozilla.org/widget/clipboardhelper;1"]
.getService(Ci.nsIClipboardHelper);
});
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
* style-inspector-overlays
* - 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: overlays.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 = overlays.VIEW_NODE_PROPERTY_TYPE;
} else if (classes.contains("property-value") ||
classes.contains("other-property-value")) {
type = overlays.VIEW_NODE_VALUE_TYPE;
} else if (isHref) {
type = overlays.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 = promise.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 = promise.defer();
this._refreshProcess = new UpdateProcess(
this.styleWindow, this.propertyViews, {
onItem: (propView) => {
propView.refresh();
},
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 keypress event in the computed view.
*/
_onKeypress: function(event) {
let isOSX = Services.appinfo.OS === "Darwin";
if (((isOSX && event.metaKey && !event.ctrlKey && !event.altKey) ||
(!isOSX && event.ctrlKey && !event.metaKey && !event.altKey)) &&
event.code === "KeyF") {
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);
} else {
this.searchField.removeAttribute("filled");
}
this.refreshPanel();
this._filterChangeTimeout = null;
}, filterTimeout);
},
/**
* Handle the search box's keypress event. If the escape key is pressed,
* clear the search box field.
*/
_onFilterKeyPress: function(event) {
if (event.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_ESCAPE &&
this._onClearSearch()) {
event.preventDefault();
event.stopPropagation();
}
},
/**
* Context menu handler for filter style search box.
*/
_onFilterTextboxContextMenu: function(event) {
try {
this.styleDocument.defaultView.focus();
let contextmenu = this.inspector.toolbox.textboxContextMenuPopup;
contextmenu.openPopupAtScreen(event.screenX, event.screenY, true);
} catch(e) {
console.error(e);
}
},
/**
* 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() {
let win = this.styleDocument.defaultView;
win.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.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 (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("keypress", this._onFilterKeyPress);
this.searchField.removeEventListener("contextmenu",
this._onFilterTextboxContextMenu);
this.searchClearButton.removeEventListener("click", this._onClearSearch);
this.includeBrowserStylesCheckbox.removeEventListener("command",
this.includeBrowserStylesChanged);
// Nodes used in templating
this.root = null;
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;
}
}
};
/**
* 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.getRTLAttr = tree.getRTLAttr;
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.onKeyDown = (event) => {
let keyEvent = Ci.nsIDOMKeyEvent;
if (event.keyCode === keyEvent.DOM_VK_F1) {
this.mdnLinkClick();
event.preventDefault();
}
if (event.keyCode === keyEvent.DOM_VK_RETURN ||
event.keyCode === keyEvent.DOM_VK_SPACE) {
this.onMatchedToggle(event);
}
};
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() {
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);
link.addEventListener("keydown", selector.maybeOpenStyleEditor, false);
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(
function(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");
}
event.preventDefault();
},
/**
* Destroy this property view, removing event listeners
*/
destroy: function() {
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 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.maybeOpenStyleEditor = this.maybeOpenStyleEditor.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 = 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}) => {
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(event) {
let keyEvent = Ci.nsIDOMKeyEvent;
if (event.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.
*/
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);
});
}
});
}
};
exports.CssComputedView = CssComputedView;
exports.PropertyView = PropertyView;