123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703 |
- /* 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;
- }
- };
|