tenfourfox/devtools/client/shared/output-parser.js
Cameron Kaiser c9b2922b70 hello FPR
2017-04-19 00:56:45 -07:00

598 lines
18 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* 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 {Cc, Ci, Cu} = require("chrome");
const {colorUtils} = require("devtools/shared/css-color");
const {Services} = Cu.import("resource://gre/modules/Services.jsm", {});
const HTML_NS = "http://www.w3.org/1999/xhtml";
const BEZIER_KEYWORDS = ["linear", "ease-in-out", "ease-in", "ease-out",
"ease"];
// Functions that accept a color argument.
const COLOR_TAKING_FUNCTIONS = ["linear-gradient",
"-moz-linear-gradient",
"repeating-linear-gradient",
"-moz-repeating-linear-gradient",
"radial-gradient",
"-moz-radial-gradient",
"repeating-radial-gradient",
"-moz-repeating-radial-gradient",
"drop-shadow"];
loader.lazyGetter(this, "DOMUtils", function() {
return Cc["@mozilla.org/inspector/dom-utils;1"].getService(Ci.inIDOMUtils);
});
/**
* This module is used to process text for output by developer tools. This means
* linking JS files with the debugger, CSS files with the style editor, JS
* functions with the debugger, placing color swatches next to colors and
* adding doorhanger previews where possible (images, angles, lengths,
* border radius, cubic-bezier etc.).
*
* Usage:
* const {require} =
* Cu.import("resource://devtools/shared/Loader.jsm", {});
* const {OutputParser} = require("devtools/client/shared/output-parser");
*
* let parser = new OutputParser(document);
*
* parser.parseCssProperty("color", "red"); // Returns document fragment.
*/
function OutputParser(document) {
this.parsed = [];
this.doc = document;
this.colorSwatches = new WeakMap();
this._onSwatchMouseDown = this._onSwatchMouseDown.bind(this);
}
exports.OutputParser = OutputParser;
OutputParser.prototype = {
/**
* Parse a CSS property value given a property name.
*
* @param {String} name
* CSS Property Name
* @param {String} value
* CSS Property value
* @param {Object} [options]
* Options object. For valid options and default values see
* _mergeOptions().
* @return {DocumentFragment}
* A document fragment containing color swatches etc.
*/
parseCssProperty: function(name, value, options={}) {
options = this._mergeOptions(options);
options.expectCubicBezier =
safeCssPropertySupportsType(name, DOMUtils.TYPE_TIMING_FUNCTION);
options.expectFilter = name === "filter";
options.supportsColor =
safeCssPropertySupportsType(name, DOMUtils.TYPE_COLOR) ||
safeCssPropertySupportsType(name, DOMUtils.TYPE_GRADIENT);
// The filter property is special in that we want to show the
// swatch even if the value is invalid, because this way the user
// can easily use the editor to fix it.
if (options.expectFilter || this._cssPropertySupportsValue(name, value)) {
return this._parse(value, options);
}
this._appendTextNode(value);
return this._toDOM();
},
/**
* Given an initial FUNCTION token, read tokens from |tokenStream|
* and collect all the (non-comment) text. Return the collected
* text. The function token and the close paren are included in the
* result.
*
* @param {CSSToken} initialToken
* The FUNCTION token.
* @param {String} text
* The original CSS text.
* @param {CSSLexer} tokenStream
* The token stream from which to read.
* @return {String}
* The text of body of the function call.
*/
_collectFunctionText: function(initialToken, text, tokenStream) {
let result = text.substring(initialToken.startOffset,
initialToken.endOffset);
let depth = 1;
while (depth > 0) {
let token = tokenStream.nextToken();
if (!token) {
break;
}
if (token.tokenType === "comment") {
continue;
}
result += text.substring(token.startOffset, token.endOffset);
if (token.tokenType === "symbol") {
if (token.text === "(") {
++depth;
} else if (token.text === ")") {
--depth;
}
} else if (token.tokenType === "function") {
++depth;
}
}
return result;
},
/**
* Parse a string.
*
* @param {String} text
* Text to parse.
* @param {Object} [options]
* Options object. For valid options and default values see
* _mergeOptions().
* @return {DocumentFragment}
* A document fragment.
*/
_parse: function(text, options={}) {
text = text.trim();
this.parsed.length = 0;
let tokenStream = DOMUtils.getCSSLexer(text);
let parenDepth = 0;
let outerMostFunctionTakesColor = false;
let colorOK = function() {
return options.supportsColor ||
(options.expectFilter && parenDepth === 1 &&
outerMostFunctionTakesColor);
};
while (true) {
let token = tokenStream.nextToken();
if (!token) {
break;
}
if (token.tokenType === "comment") {
continue;
}
switch (token.tokenType) {
case "function": {
if (COLOR_TAKING_FUNCTIONS.indexOf(token.text) >= 0) {
// The function can accept a color argument, and we know
// it isn't special in some other way. So, we let it
// through to the ordinary parsing loop so that colors
// can be handled in a single place.
this._appendTextNode(text.substring(token.startOffset,
token.endOffset));
if (parenDepth === 0) {
outerMostFunctionTakesColor = true;
}
++parenDepth;
} else {
let functionText = this._collectFunctionText(token, text,
tokenStream);
if (options.expectCubicBezier && token.text === "cubic-bezier") {
this._appendCubicBezier(functionText, options);
} else if (colorOK() && DOMUtils.isValidCSSColor(functionText)) {
this._appendColor(functionText, options);
} else {
this._appendTextNode(functionText);
}
}
break;
}
case "ident":
if (options.expectCubicBezier &&
BEZIER_KEYWORDS.indexOf(token.text) >= 0) {
this._appendCubicBezier(token.text, options);
} else if (colorOK() && DOMUtils.isValidCSSColor(token.text)) {
this._appendColor(token.text, options);
} else {
this._appendTextNode(text.substring(token.startOffset,
token.endOffset));
}
break;
case "id":
case "hash": {
let original = text.substring(token.startOffset, token.endOffset);
if (colorOK() && DOMUtils.isValidCSSColor(original)) {
this._appendColor(original, options);
} else {
this._appendTextNode(original);
}
break;
}
case "url":
case "bad_url":
this._appendURL(text.substring(token.startOffset, token.endOffset),
token.text, options);
break;
case "symbol":
if (token.text === "(") {
++parenDepth;
} else if (token.token === ")") {
--parenDepth;
}
// falls through
default:
this._appendTextNode(text.substring(token.startOffset,
token.endOffset));
break;
}
}
let result = this._toDOM();
if (options.expectFilter && !options.filterSwatch) {
result = this._wrapFilter(text, options, result);
}
return result;
},
/**
* Append a cubic-bezier timing function value to the output
*
* @param {String} bezier
* The cubic-bezier timing function
* @param {Object} options
* Options object. For valid options and default values see
* _mergeOptions()
*/
_appendCubicBezier: function(bezier, options) {
let container = this._createNode("span", {
"data-bezier": bezier
});
if (options.bezierSwatchClass) {
let swatch = this._createNode("span", {
class: options.bezierSwatchClass
});
container.appendChild(swatch);
}
let value = this._createNode("span", {
class: options.bezierClass
}, bezier);
container.appendChild(value);
this.parsed.push(container);
},
/**
* Check if a CSS property supports a specific value.
*
* @param {String} name
* CSS Property name to check
* @param {String} value
* CSS Property value to check
*/
_cssPropertySupportsValue: function(name, value) {
return DOMUtils.cssPropertyIsValid(name, value);
},
/**
* Tests if a given colorObject output by CssColor is valid for parsing.
* Valid means it's really a color, not any of the CssColor SPECIAL_VALUES
* except transparent
*/
_isValidColor: function(colorObj) {
return colorObj.valid &&
(!colorObj.specialValue || colorObj.specialValue === "transparent");
},
/**
* Append a color to the output.
*
* @param {String} color
* Color to append
* @param {Object} [options]
* Options object. For valid options and default values see
* _mergeOptions().
*/
_appendColor: function(color, options={}) {
let colorObj = new colorUtils.CssColor(color);
if (this._isValidColor(colorObj)) {
let container = this._createNode("span", {
"data-color": color
});
if (options.colorSwatchClass) {
let swatch = this._createNode("span", {
class: options.colorSwatchClass,
style: "background-color:" + color
});
this.colorSwatches.set(swatch, colorObj);
swatch.addEventListener("mousedown", this._onSwatchMouseDown, false);
container.appendChild(swatch);
}
if (options.defaultColorType) {
color = colorObj.toString();
container.dataset.color = color;
}
let value = this._createNode("span", {
class: options.colorClass
}, color);
container.appendChild(value);
this.parsed.push(container);
} else {
this._appendTextNode(color);
}
},
/**
* Wrap some existing nodes in a filter editor.
*
* @param {String} filters
* The full text of the "filter" property.
* @param {object} options
* The options object passed to parseCssProperty().
* @param {object} nodes
* Nodes created by _toDOM().
*
* @returns {object}
* A new node that supplies a filter swatch and that wraps |nodes|.
*/
_wrapFilter: function(filters, options, nodes) {
let container = this._createNode("span", {
"data-filters": filters
});
if (options.filterSwatchClass) {
let swatch = this._createNode("span", {
class: options.filterSwatchClass
});
container.appendChild(swatch);
}
let value = this._createNode("span", {
class: options.filterClass
});
value.appendChild(nodes);
container.appendChild(value);
return container;
},
_onSwatchMouseDown: function(event) {
// Prevent text selection in the case of shift-click or double-click.
event.preventDefault();
if (!event.shiftKey) {
return;
}
let swatch = event.target;
let color = this.colorSwatches.get(swatch);
let val = color.nextColorUnit();
swatch.nextElementSibling.textContent = val;
},
/**
* A helper function that sanitizes a possibly-unterminated URL.
*/
_sanitizeURL: function(url) {
// Re-lex the URL and add any needed termination characters.
let urlTokenizer = DOMUtils.getCSSLexer(url);
// Just read until EOF; there will only be a single token.
while (urlTokenizer.nextToken()) {
// Nothing.
}
return urlTokenizer.performEOFFixup(url, true);
},
/**
* Append a URL to the output.
*
* @param {String} match
* Complete match that may include "url(xxx)"
* @param {String} url
* Actual URL
* @param {Object} [options]
* Options object. For valid options and default values see
* _mergeOptions().
*/
_appendURL: function(match, url, options) {
if (options.urlClass) {
// Sanitize the URL. Note that if we modify the URL, we just
// leave the termination characters. This isn't strictly
// "as-authored", but it makes a bit more sense.
match = this._sanitizeURL(match);
// This regexp matches a URL token. It puts the "url(", any
// leading whitespace, and any opening quote into |leader|; the
// URL text itself into |body|, and any trailing quote, trailing
// whitespace, and the ")" into |trailer|. We considered adding
// functionality for this to CSSLexer, in some way, but this
// seemed simpler on the whole.
let [, leader, , body, trailer] =
/^(url\([ \t\r\n\f]*(["']?))(.*?)(\2[ \t\r\n\f]*\))$/i.exec(match);
this._appendTextNode(leader);
let href = url;
if (options.baseURI) {
href = options.baseURI.resolve(url);
}
this._appendNode("a", {
target: "_blank",
class: options.urlClass,
href: href
}, body);
this._appendTextNode(trailer);
} else {
this._appendTextNode(match);
}
},
/**
* Create a node.
*
* @param {String} tagName
* Tag type e.g. "div"
* @param {Object} attributes
* e.g. {class: "someClass", style: "cursor:pointer"};
* @param {String} [value]
* If a value is included it will be appended as a text node inside
* the tag. This is useful e.g. for span tags.
* @return {Node} Newly created Node.
*/
_createNode: function(tagName, attributes, value="") {
let node = this.doc.createElementNS(HTML_NS, tagName);
let attrs = Object.getOwnPropertyNames(attributes);
for (let attr of attrs) {
if (attributes[attr]) {
node.setAttribute(attr, attributes[attr]);
}
}
if (value) {
let textNode = this.doc.createTextNode(value);
node.appendChild(textNode);
}
return node;
},
/**
* Append a node to the output.
*
* @param {String} tagName
* Tag type e.g. "div"
* @param {Object} attributes
* e.g. {class: "someClass", style: "cursor:pointer"};
* @param {String} [value]
* If a value is included it will be appended as a text node inside
* the tag. This is useful e.g. for span tags.
*/
_appendNode: function(tagName, attributes, value="") {
let node = this._createNode(tagName, attributes, value);
this.parsed.push(node);
},
/**
* Append a text node to the output. If the previously output item was a text
* node then we append the text to that node.
*
* @param {String} text
* Text to append
*/
_appendTextNode: function(text) {
let lastItem = this.parsed[this.parsed.length - 1];
if (typeof lastItem === "string") {
this.parsed[this.parsed.length - 1] = lastItem + text;
} else {
this.parsed.push(text);
}
},
/**
* Take all output and append it into a single DocumentFragment.
*
* @return {DocumentFragment}
* Document Fragment
*/
_toDOM: function() {
let frag = this.doc.createDocumentFragment();
for (let item of this.parsed) {
if (typeof item === "string") {
frag.appendChild(this.doc.createTextNode(item));
} else {
frag.appendChild(item);
}
}
this.parsed.length = 0;
return frag;
},
/**
* Merges options objects. Default values are set here.
*
* @param {Object} overrides
* The option values to override e.g. _mergeOptions({colors: false})
*
* Valid options are:
* - defaultColorType: true // Convert colors to the default type
* // selected in the options panel.
* - colorSwatchClass: "" // The class to use for color swatches.
* - colorClass: "" // The class to use for the color value
* // that follows the swatch.
* - bezierSwatchClass: "" // The class to use for bezier swatches.
* - bezierClass: "" // The class to use for the bezier value
* // that follows the swatch.
* - supportsColor: false // Does the CSS property support colors?
* - urlClass: "" // The class to be used for url() links.
* - baseURI: "" // A string or nsIURI used to resolve
* // relative links.
* - filterSwatch: false // A special case for parsing a
* // "filter" property, causing the
* // parser to skip the call to
* // _wrapFilter. Used only for
* // previewing with the filter swatch.
* @return {Object}
* Overridden options object
*/
_mergeOptions: function(overrides) {
let defaults = {
defaultColorType: true,
colorSwatchClass: "",
colorClass: "",
bezierSwatchClass: "",
bezierClass: "",
supportsColor: false,
urlClass: "",
baseURI: "",
filterSwatch: false
};
if (typeof overrides.baseURI === "string") {
overrides.baseURI = Services.io.newURI(overrides.baseURI, null, null);
}
for (let item in overrides) {
defaults[item] = overrides[item];
}
return defaults;
}
};
/**
* A wrapper for DOMUtils.cssPropertySupportsType that ignores invalid
* properties.
*
* @param {String} name The property name.
* @param {number} type The type tested for support.
* @return {Boolean} Whether the property supports the type.
* If the property is unknown, false is returned.
*/
function safeCssPropertySupportsType(name, type) {
try {
return DOMUtils.cssPropertySupportsType(name, type);
} catch(e) {
return false;
}
}