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