Mypal/devtools/client/shared/output-parser.js

703 lines
22 KiB
JavaScript
Raw Permalink 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 {angleUtils} = require("devtools/client/shared/css-angle");
const {colorUtils} = require("devtools/shared/css/color");
const {getCSSLexer} = require("devtools/shared/css/lexer");
const EventEmitter = require("devtools/shared/event-emitter");
const {
ANGLE_TAKING_FUNCTIONS,
BEZIER_KEYWORDS,
COLOR_TAKING_FUNCTIONS,
CSS_TYPES
} = require("devtools/shared/css/properties-db");
const Services = require("Services");
const HTML_NS = "http://www.w3.org/1999/xhtml";
const CSS_GRID_ENABLED_PREF = "layout.css.grid.enabled";
/**
* 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 {OutputParser} = require("devtools/client/shared/output-parser");
*
* let parser = new OutputParser(document, supportsType);
*
* parser.parseCssProperty("color", "red"); // Returns document fragment.
*
* @param {Document} document Used to create DOM nodes.
* @param {Function} supportsTypes - A function that returns a boolean when asked if a css
* property name supports a given css type.
* The function is executed like supportsType("color", CSS_TYPES.COLOR)
* where CSS_TYPES is defined in devtools/shared/css/properties-db.js
* @param {Function} isValidOnClient - A function that checks if a css property
* name/value combo is valid.
* @param {Function} supportsCssColor4ColorFunction - A function for checking
* the supporting of css-color-4 color function.
*/
function OutputParser(document,
{supportsType, isValidOnClient, supportsCssColor4ColorFunction}) {
this.parsed = [];
this.doc = document;
this.supportsType = supportsType;
this.isValidOnClient = isValidOnClient;
this.colorSwatches = new WeakMap();
this.angleSwatches = new WeakMap();
this._onColorSwatchMouseDown = this._onColorSwatchMouseDown.bind(this);
this._onAngleSwatchMouseDown = this._onAngleSwatchMouseDown.bind(this);
this.cssColor4 = supportsCssColor4ColorFunction();
}
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 = this.supportsType(name, CSS_TYPES.TIMING_FUNCTION);
options.expectDisplay = name === "display";
options.expectFilter = name === "filter";
options.supportsColor = this.supportsType(name, CSS_TYPES.COLOR) ||
this.supportsType(name, CSS_TYPES.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 = getCSSLexer(text);
let parenDepth = 0;
let outerMostFunctionTakesColor = false;
let colorOK = function () {
return options.supportsColor ||
(options.expectFilter && parenDepth === 1 &&
outerMostFunctionTakesColor);
};
let angleOK = function (angle) {
return (new angleUtils.CssAngle(angle)).valid;
};
while (true) {
let token = tokenStream.nextToken();
if (!token) {
break;
}
if (token.tokenType === "comment") {
continue;
}
switch (token.tokenType) {
case "function": {
if (COLOR_TAKING_FUNCTIONS.includes(token.text) ||
ANGLE_TAKING_FUNCTIONS.includes(token.text)) {
// The function can accept a color or an angle argument, and we know
// it isn't special in some other way. So, we let it
// through to the ordinary parsing loop so that the value
// can be handled in a single place.
this._appendTextNode(text.substring(token.startOffset,
token.endOffset));
if (parenDepth === 0) {
outerMostFunctionTakesColor = COLOR_TAKING_FUNCTIONS.includes(
token.text);
}
++parenDepth;
} else {
let functionText = this._collectFunctionText(token, text,
tokenStream);
if (options.expectCubicBezier && token.text === "cubic-bezier") {
this._appendCubicBezier(functionText, options);
} else if (colorOK() &&
colorUtils.isValidCSSColor(functionText, this.cssColor4)) {
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 (Services.prefs.getBoolPref(CSS_GRID_ENABLED_PREF) &&
options.expectDisplay && token.text === "grid" &&
text === token.text) {
this._appendGrid(token.text, options);
} else if (colorOK() &&
colorUtils.isValidCSSColor(token.text, this.cssColor4)) {
this._appendColor(token.text, options);
} else if (angleOK(token.text)) {
this._appendAngle(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() && colorUtils.isValidCSSColor(original, this.cssColor4)) {
this._appendColor(original, options);
} else {
this._appendTextNode(original);
}
break;
}
case "dimension":
let value = text.substring(token.startOffset, token.endOffset);
if (angleOK(value)) {
this._appendAngle(value, options);
} else {
this._appendTextNode(value);
}
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.text === ")") {
--parenDepth;
if (parenDepth === 0) {
outerMostFunctionTakesColor = false;
}
}
// 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);
},
/**
* Append a CSS Grid highlighter toggle icon next to the value in a
* 'display: grid' declaration
*
* @param {String} grid
* The grid text value to append
* @param {Object} options
* Options object. For valid options and default values see
* _mergeOptions()
*/
_appendGrid: function (grid, options) {
let container = this._createNode("span", {});
let toggle = this._createNode("span", {
class: options.gridClass
});
let value = this._createNode("span", {});
value.textContent = grid;
container.appendChild(toggle);
container.appendChild(value);
this.parsed.push(container);
},
/**
* Append a angle value to the output
*
* @param {String} angle
* angle to append
* @param {Object} options
* Options object. For valid options and default values see
* _mergeOptions()
*/
_appendAngle: function (angle, options) {
let angleObj = new angleUtils.CssAngle(angle);
let container = this._createNode("span", {
"data-angle": angle
});
if (options.angleSwatchClass) {
let swatch = this._createNode("span", {
class: options.angleSwatchClass
});
this.angleSwatches.set(swatch, angleObj);
swatch.addEventListener("mousedown", this._onAngleSwatchMouseDown, false);
// Add click listener to stop event propagation when shift key is pressed
// in order to prevent the value input to be focused.
// Bug 711942 will add a tooltip to edit angle values and we should
// be able to move this listener to Tooltip.js when it'll be implemented.
swatch.addEventListener("click", function (event) {
if (event.shiftKey) {
event.stopPropagation();
}
}, false);
EventEmitter.decorate(swatch);
container.appendChild(swatch);
}
let value = this._createNode("span", {
class: options.angleClass
}, angle);
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 this.isValidOnClient(name, value, this.doc);
},
/**
* 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, this.cssColor4);
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._onColorSwatchMouseDown,
false);
EventEmitter.decorate(swatch);
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;
},
_onColorSwatchMouseDown: function (event) {
if (!event.shiftKey) {
return;
}
// Prevent click event to be fired to not show the tooltip
event.stopPropagation();
let swatch = event.target;
let color = this.colorSwatches.get(swatch);
let val = color.nextColorUnit();
swatch.nextElementSibling.textContent = val;
swatch.emit("unit-change", val);
},
_onAngleSwatchMouseDown: function (event) {
if (!event.shiftKey) {
return;
}
event.stopPropagation();
let swatch = event.target;
let angle = this.angleSwatches.get(swatch);
let val = angle.nextAngleUnit();
swatch.nextElementSibling.textContent = val;
swatch.emit("unit-change", 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 = 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) {
try {
href = new URL(url, options.baseURI).href;
} catch (e) {
// Ignore.
}
}
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.
* - angleClass: "" // The class to use for the angle value
* // that follows the swatch.
* - angleSwatchClass: "" // The class to use for angle swatches.
* - bezierClass: "" // The class to use for the bezier value
* // that follows the swatch.
* - bezierSwatchClass: "" // The class to use for bezier swatches.
* - colorClass: "" // The class to use for the color value
* // that follows the swatch.
* - colorSwatchClass: "" // The class to use for color swatches.
* - 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.
* - gridClass: "" // The class to use for the grid icon.
* - supportsColor: false // Does the CSS property support colors?
* - urlClass: "" // The class to be used for url() links.
* - baseURI: undefined // A string used to resolve
* // relative links.
* @return {Object}
* Overridden options object
*/
_mergeOptions: function (overrides) {
let defaults = {
defaultColorType: true,
angleClass: "",
angleSwatchClass: "",
bezierClass: "",
bezierSwatchClass: "",
colorClass: "",
colorSwatchClass: "",
filterSwatch: false,
gridClass: "",
supportsColor: false,
urlClass: "",
baseURI: undefined,
};
for (let item in overrides) {
defaults[item] = overrides[item];
}
return defaults;
}
};