From a90a5cba08fdf6c0ceb95101c275108a152a3aed Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Wed, 12 Jun 2024 07:35:37 +0200 Subject: Merging upstream version 127.0. Signed-off-by: Daniel Baumann --- .../components/object-inspector/utils/node.js | 5 +- devtools/client/shared/css-angle.js | 11 +- devtools/client/shared/output-parser.js | 316 ++++++----- devtools/client/shared/screenshot.js | 14 +- .../shared/sourceeditor/css-autocompleter.js | 578 +++++++++++---------- devtools/client/shared/sourceeditor/editor.js | 324 ++++++++++-- .../client/shared/test/browser_filter-editor-01.js | 5 +- .../client/shared/test/browser_outputparser.js | 16 + .../shared/test/xpcshell/test_parseDeclarations.js | 23 +- .../test/xpcshell/test_rewriteDeclarations.js | 4 +- .../client/shared/widgets/CubicBezierWidget.js | 18 +- devtools/client/shared/widgets/FilterWidget.js | 69 ++- .../shared/widgets/LinearEasingFunctionWidget.js | 16 +- 13 files changed, 867 insertions(+), 532 deletions(-) (limited to 'devtools/client/shared') diff --git a/devtools/client/shared/components/object-inspector/utils/node.js b/devtools/client/shared/components/object-inspector/utils/node.js index 0d049e1c4b..63d358e580 100644 --- a/devtools/client/shared/components/object-inspector/utils/node.js +++ b/devtools/client/shared/components/object-inspector/utils/node.js @@ -283,10 +283,7 @@ function nodeHasEntries(item) { className === "MIDIInputMap" || className === "MIDIOutputMap" || className === "HighlightRegistry" || - // @backward-compat { version 125 } Support for enumerate CustomStateSet items was - // added in 125. When connecting to older server, we don't want to show the - // node for them. The extra check can be removed once 125 hits release. - (className === "CustomStateSet" && Array.isArray(value.preview?.items)) + className === "CustomStateSet" ); } diff --git a/devtools/client/shared/css-angle.js b/devtools/client/shared/css-angle.js index 903b7813ad..d89cba5b7f 100644 --- a/devtools/client/shared/css-angle.js +++ b/devtools/client/shared/css-angle.js @@ -6,7 +6,9 @@ const SPECIALVALUES = new Set(["initial", "inherit", "unset"]); -const { getCSSLexer } = require("resource://devtools/shared/css/lexer.js"); +const { + InspectorCSSParserWrapper, +} = require("resource://devtools/shared/css/lexer.js"); loader.lazyRequireGetter( this, @@ -68,13 +70,14 @@ CssAngle.prototype = { }, get valid() { - const token = getCSSLexer(this.authored).nextToken(); + const token = new InspectorCSSParserWrapper(this.authored).nextToken(); if (!token) { return false; } + return ( - token.tokenType === "dimension" && - token.text.toLowerCase() in this.ANGLEUNIT + token.tokenType === "Dimension" && + token.unit.toLowerCase() in this.ANGLEUNIT ); }, diff --git a/devtools/client/shared/output-parser.js b/devtools/client/shared/output-parser.js index bd514096b3..5202ae3a8e 100644 --- a/devtools/client/shared/output-parser.js +++ b/devtools/client/shared/output-parser.js @@ -8,7 +8,9 @@ const { angleUtils, } = require("resource://devtools/client/shared/css-angle.js"); const { colorUtils } = require("resource://devtools/shared/css/color.js"); -const { getCSSLexer } = require("resource://devtools/shared/css/lexer.js"); +const { + InspectorCSSParserWrapper, +} = require("resource://devtools/shared/css/lexer.js"); const { appendText, } = require("resource://devtools/client/inspector/shared/utils.js"); @@ -67,6 +69,13 @@ const BACKDROP_FILTER_ENABLED = Services.prefs.getBoolPref( ); const HTML_NS = "http://www.w3.org/1999/xhtml"; +// 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|. +const URL_REGEX = + /^(?url\([ \t\r\n\f]*(["']?))(?.*?)(?\2[ \t\r\n\f]*\))$/i; + // Very long text properties should be truncated using CSS to avoid creating // extremely tall propertyvalue containers. 5000 characters is an arbitrary // limit. Assuming an average ruleview can hold 50 characters per line, this @@ -175,7 +184,7 @@ class OutputParser { * @param {Boolean} stopAtComma * If true, stop at a comma. * @return {Object} - * An object of the form {tokens, functionData, sawComma, sawVariable}. + * An object of the form {tokens, functionData, sawComma, sawVariable, depth}. * |tokens| is a list of the non-comment, non-whitespace tokens * that were seen. The stopping token (paren or comma) will not * be included. @@ -184,6 +193,7 @@ class OutputParser { * not be included. * |sawComma| is true if the stop was due to a comma, or false otherwise. * |sawVariable| is true if a variable was seen while parsing the text. + * |depth| is the number of unclosed parenthesis remaining when we return. */ #parseMatchingParens(text, tokenStream, options, stopAtComma) { let depth = 1; @@ -196,24 +206,22 @@ class OutputParser { if (!token) { break; } - if (token.tokenType === "comment") { + if (token.tokenType === "Comment") { continue; } - if (token.tokenType === "symbol") { - if (stopAtComma && depth === 1 && token.text === ",") { - return { tokens, functionData, sawComma: true, sawVariable }; - } else if (token.text === "(") { - ++depth; - } else if (token.text === ")") { - --depth; - if (depth === 0) { - break; - } + if (stopAtComma && depth === 1 && token.tokenType === "Comma") { + return { tokens, functionData, sawComma: true, sawVariable, depth }; + } else if (token.tokenType === "ParenthesisBlock") { + ++depth; + } else if (token.tokenType === "CloseParenthesis") { + --depth; + if (depth === 0) { + break; } } else if ( - token.tokenType === "function" && - token.text === "var" && + token.tokenType === "Function" && + token.value === "var" && options.getVariableValue ) { sawVariable = true; @@ -224,24 +232,24 @@ class OutputParser { options ); functionData.push({ node, value, fallbackValue }); - } else if (token.tokenType === "function") { + } else if (token.tokenType === "Function") { ++depth; } if ( - token.tokenType !== "function" || - token.text !== "var" || + token.tokenType !== "Function" || + token.value !== "var" || !options.getVariableValue ) { functionData.push(text.substring(token.startOffset, token.endOffset)); } - if (token.tokenType !== "whitespace") { + if (token.tokenType !== "WhiteSpace") { tokens.push(token); } } - return { tokens, functionData, sawComma: false, sawVariable }; + return { tokens, functionData, sawComma: false, sawVariable, depth }; } /** @@ -389,19 +397,23 @@ class OutputParser { } const lowerCaseTokenText = token.text?.toLowerCase(); - if (token.tokenType === "comment") { + if (token.tokenType === "Comment") { // This doesn't change spaceNeeded, because we didn't emit // anything to the output. continue; } switch (token.tokenType) { - case "function": { - const isColorTakingFunction = - COLOR_TAKING_FUNCTIONS.includes(lowerCaseTokenText); + case "Function": { + const functionName = token.value; + const lowerCaseFunctionName = functionName.toLowerCase(); + + const isColorTakingFunction = COLOR_TAKING_FUNCTIONS.includes( + lowerCaseFunctionName + ); if ( isColorTakingFunction || - ANGLE_TAKING_FUNCTIONS.includes(lowerCaseTokenText) + ANGLE_TAKING_FUNCTIONS.includes(lowerCaseFunctionName) ) { // 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 @@ -414,10 +426,13 @@ class OutputParser { outerMostFunctionTakesColor = isColorTakingFunction; } if (isColorTakingFunction) { - colorFunctions.push({ parenDepth, functionName: token.text }); + colorFunctions.push({ parenDepth, functionName }); } ++parenDepth; - } else if (lowerCaseTokenText === "var" && options.getVariableValue) { + } else if ( + lowerCaseFunctionName === "var" && + options.getVariableValue + ) { const { node: variableNode, value } = this.#parseVariable( token, text, @@ -434,20 +449,17 @@ class OutputParser { this.#parsed.push(variableNode); } } else { - const { functionData, sawVariable } = this.#parseMatchingParens( - text, - tokenStream, - options - ); - - const functionName = text.substring( - token.startOffset, - token.endOffset - ); + const { + functionData, + sawVariable, + tokens: functionArgTokens, + depth, + } = this.#parseMatchingParens(text, tokenStream, options); if (sawVariable) { const computedFunctionText = functionName + + "(" + functionData .map(data => { if (typeof data === "string") { @@ -466,6 +478,7 @@ class OutputParser { colorFunction: colorFunctions.at(-1)?.functionName, valueParts: [ functionName, + "(", ...functionData.map(data => data.node || data), ")", ], @@ -473,7 +486,7 @@ class OutputParser { } else { // If function contains variable, we need to add both strings // and nodes. - this.#appendTextNode(functionName); + this.#appendTextNode(functionName + "("); for (const data of functionData) { if (typeof data === "string") { this.#appendTextNode(data); @@ -486,16 +499,40 @@ class OutputParser { } else { // If no variable in function, join the text together and add // to DOM accordingly. - const functionText = functionName + functionData.join("") + ")"; + const functionText = + functionName + + "(" + + functionData.join("") + + // only append closing parenthesis if the authored text actually had it + // In such case, we should probably indicate that there's a "syntax error" + // See Bug 1891461. + (depth == 0 ? ")" : ""); + + if (lowerCaseFunctionName === "url" && options.urlClass) { + // url() with quoted strings are not mapped as UnquotedUrl, + // instead, we get a "Function" token with "url" function name, + // and later, a "QuotedString" token, which contains the actual URL. + let url; + for (const argToken of functionArgTokens) { + if (argToken.tokenType === "QuotedString") { + url = argToken.value; + break; + } + } - if ( + if (url !== undefined) { + this.#appendURL(functionText, url, options); + } else { + this.#appendTextNode(functionText); + } + } else if ( options.expectCubicBezier && - lowerCaseTokenText === "cubic-bezier" + lowerCaseFunctionName === "cubic-bezier" ) { this.#appendCubicBezier(functionText, options); } else if ( options.expectLinearEasing && - lowerCaseTokenText === "linear" + lowerCaseFunctionName === "linear" ) { this.#appendLinear(functionText, options); } else if ( @@ -508,7 +545,7 @@ class OutputParser { }); } else if ( options.expectShape && - BASIC_SHAPE_FUNCTIONS.includes(lowerCaseTokenText) + BASIC_SHAPE_FUNCTIONS.includes(lowerCaseFunctionName) ) { this.#appendShape(functionText, options); } else { @@ -519,7 +556,7 @@ class OutputParser { break; } - case "ident": + case "Ident": if ( options.expectCubicBezier && BEZIER_KEYWORDS.includes(lowerCaseTokenText) @@ -553,8 +590,8 @@ class OutputParser { } break; - case "id": - case "hash": { + case "IDHash": + case "Hash": { const original = text.substring(token.startOffset, token.endOffset); if (colorOK() && InspectorUtils.isValidCSSColor(original)) { if (spaceNeeded) { @@ -571,7 +608,7 @@ class OutputParser { } break; } - case "dimension": + case "Dimension": const value = text.substring(token.startOffset, token.endOffset); if (angleOK(value)) { this.#appendAngle(value, options); @@ -579,16 +616,16 @@ class OutputParser { this.#appendTextNode(value); } break; - case "url": - case "bad_url": + case "UnquotedUrl": + case "BadUrl": this.#appendURL( text.substring(token.startOffset, token.endOffset), - token.text, + token.value, options ); break; - case "string": + case "QuotedString": if (options.expectFont) { fontFamilyNameParts.push( text.substring(token.startOffset, token.endOffset) @@ -600,7 +637,7 @@ class OutputParser { } break; - case "whitespace": + case "WhiteSpace": if (options.expectFont) { fontFamilyNameParts.push(" "); } else { @@ -610,32 +647,44 @@ class OutputParser { } break; - case "symbol": - if (token.text === "(") { - ++parenDepth; - } else if (token.text === ")") { - --parenDepth; + case "ParenthesisBlock": + ++parenDepth; + this.#appendTextNode( + text.substring(token.startOffset, token.endOffset) + ); + break; - if (colorFunctions.at(-1)?.parenDepth == parenDepth) { - colorFunctions.pop(); - } + case "CloseParenthesis": + --parenDepth; - if (stopAtCloseParen && parenDepth === 0) { - done = true; - break; - } + if (colorFunctions.at(-1)?.parenDepth == parenDepth) { + colorFunctions.pop(); + } - if (parenDepth === 0) { - outerMostFunctionTakesColor = false; - } - } else if ( - (token.text === "," || token.text === "!") && + if (stopAtCloseParen && parenDepth === 0) { + done = true; + break; + } + + if (parenDepth === 0) { + outerMostFunctionTakesColor = false; + } + this.#appendTextNode( + text.substring(token.startOffset, token.endOffset) + ); + break; + + case "Comma": + case "Delim": + if ( + (token.tokenType === "Comma" || token.text === "!") && options.expectFont && fontFamilyNameParts.length !== 0 ) { this.#appendFontFamily(fontFamilyNameParts.join(""), options); fontFamilyNameParts = []; } + // falls through default: this.#appendTextNode( @@ -647,15 +696,15 @@ class OutputParser { // If this token might possibly introduce token pasting when // color-cycling, require a space. spaceNeeded = - token.tokenType === "ident" || - token.tokenType === "at" || - token.tokenType === "id" || - token.tokenType === "hash" || - token.tokenType === "number" || - token.tokenType === "dimension" || - token.tokenType === "percentage" || - token.tokenType === "dimension"; - previousWasBang = token.tokenType === "symbol" && token.text === "!"; + token.tokenType === "Ident" || + token.tokenType === "AtKeyword" || + token.tokenType === "IDHash" || + token.tokenType === "Hash" || + token.tokenType === "Number" || + token.tokenType === "Dimension" || + token.tokenType === "Percentage" || + token.tokenType === "Dimension"; + previousWasBang = token.tokenType === "Delim" && token.text === "!"; } if (options.expectFont && fontFamilyNameParts.length !== 0) { @@ -686,7 +735,7 @@ class OutputParser { text = text.trim(); this.#parsed.length = 0; - const tokenStream = getCSSLexer(text); + const tokenStream = new InspectorCSSParserWrapper(text); return this.#doParse(text, options, tokenStream, false); } @@ -884,7 +933,7 @@ class OutputParser { */ // eslint-disable-next-line complexity #addPolygonPointNodes(coords, container) { - const tokenStream = getCSSLexer(coords); + const tokenStream = new InspectorCSSParserWrapper(coords); let token = tokenStream.nextToken(); let coord = ""; let i = 0; @@ -897,7 +946,7 @@ class OutputParser { }); while (token) { - if (token.tokenType === "symbol" && token.text === ",") { + if (token.tokenType === "Comma") { // Comma separating coordinate pairs; add coordNode to container and reset vars if (!isXCoord) { // Y coord not added to coordNode yet @@ -933,19 +982,19 @@ class OutputParser { class: "ruleview-shape-point", "data-point": `${i}`, }); - } else if (token.tokenType === "symbol" && token.text === "(") { + } else if (token.tokenType === "ParenthesisBlock") { depth++; coord += coords.substring(token.startOffset, token.endOffset); - } else if (token.tokenType === "symbol" && token.text === ")") { + } else if (token.tokenType === "CloseParenthesis") { depth--; coord += coords.substring(token.startOffset, token.endOffset); - } else if (token.tokenType === "whitespace" && coord === "") { + } else if (token.tokenType === "WhiteSpace" && coord === "") { // Whitespace at beginning of coord; add to container appendText( container, coords.substring(token.startOffset, token.endOffset) ); - } else if (token.tokenType === "whitespace" && depth === 0) { + } else if (token.tokenType === "WhiteSpace" && depth === 0) { // Whitespace signifying end of coord const node = this.#createNode( "span", @@ -964,10 +1013,10 @@ class OutputParser { coord = ""; isXCoord = !isXCoord; } else if ( - token.tokenType === "number" || - token.tokenType === "dimension" || - token.tokenType === "percentage" || - token.tokenType === "function" + token.tokenType === "Number" || + token.tokenType === "Dimension" || + token.tokenType === "Percentage" || + token.tokenType === "Function" ) { if (isXCoord && coord && depth === 0) { // Whitespace is not necessary between x/y coords. @@ -986,11 +1035,11 @@ class OutputParser { } coord += coords.substring(token.startOffset, token.endOffset); - if (token.tokenType === "function") { + if (token.tokenType === "Function") { depth++; } } else if ( - token.tokenType === "ident" && + token.tokenType === "Ident" && (token.text === "nonzero" || token.text === "evenodd") ) { // A fill-rule (nonzero or evenodd). @@ -1034,7 +1083,7 @@ class OutputParser { */ // eslint-disable-next-line complexity #addCirclePointNodes(coords, container) { - const tokenStream = getCSSLexer(coords); + const tokenStream = new InspectorCSSParserWrapper(coords); let token = tokenStream.nextToken(); let depth = 0; let coord = ""; @@ -1044,20 +1093,20 @@ class OutputParser { "data-point": "center", }); while (token) { - if (token.tokenType === "symbol" && token.text === "(") { + if (token.tokenType === "ParenthesisBlock") { depth++; coord += coords.substring(token.startOffset, token.endOffset); - } else if (token.tokenType === "symbol" && token.text === ")") { + } else if (token.tokenType === "CloseParenthesis") { depth--; coord += coords.substring(token.startOffset, token.endOffset); - } else if (token.tokenType === "whitespace" && coord === "") { + } else if (token.tokenType === "WhiteSpace" && coord === "") { // Whitespace at beginning of coord; add to container appendText( container, coords.substring(token.startOffset, token.endOffset) ); } else if ( - token.tokenType === "whitespace" && + token.tokenType === "WhiteSpace" && point === "radius" && depth === 0 ) { @@ -1078,7 +1127,7 @@ class OutputParser { point = "cx"; coord = ""; depth = 0; - } else if (token.tokenType === "whitespace" && depth === 0) { + } else if (token.tokenType === "WhiteSpace" && depth === 0) { // Whitespace signifying end of cx/cy const node = this.#createNode( "span", @@ -1097,7 +1146,7 @@ class OutputParser { point = point === "cx" ? "cy" : "cx"; coord = ""; depth = 0; - } else if (token.tokenType === "ident" && token.text === "at") { + } else if (token.tokenType === "Ident" && token.text === "at") { // "at"; Add radius to container if not already done so if (point === "radius" && coord) { const node = this.#createNode( @@ -1118,10 +1167,10 @@ class OutputParser { coord = ""; depth = 0; } else if ( - token.tokenType === "number" || - token.tokenType === "dimension" || - token.tokenType === "percentage" || - token.tokenType === "function" + token.tokenType === "Number" || + token.tokenType === "Dimension" || + token.tokenType === "Percentage" || + token.tokenType === "Function" ) { if (point === "cx" && coord && depth === 0) { // Center coords don't require whitespace between x/y. So if current point is @@ -1142,7 +1191,7 @@ class OutputParser { } coord += coords.substring(token.startOffset, token.endOffset); - if (token.tokenType === "function") { + if (token.tokenType === "Function") { depth++; } } else { @@ -1195,7 +1244,7 @@ class OutputParser { */ // eslint-disable-next-line complexity #addEllipsePointNodes(coords, container) { - const tokenStream = getCSSLexer(coords); + const tokenStream = new InspectorCSSParserWrapper(coords); let token = tokenStream.nextToken(); let depth = 0; let coord = ""; @@ -1205,19 +1254,19 @@ class OutputParser { "data-point": "center", }); while (token) { - if (token.tokenType === "symbol" && token.text === "(") { + if (token.tokenType === "ParenthesisBlock") { depth++; coord += coords.substring(token.startOffset, token.endOffset); - } else if (token.tokenType === "symbol" && token.text === ")") { + } else if (token.tokenType === "CloseParenthesis") { depth--; coord += coords.substring(token.startOffset, token.endOffset); - } else if (token.tokenType === "whitespace" && coord === "") { + } else if (token.tokenType === "WhiteSpace" && coord === "") { // Whitespace at beginning of coord; add to container appendText( container, coords.substring(token.startOffset, token.endOffset) ); - } else if (token.tokenType === "whitespace" && depth === 0) { + } else if (token.tokenType === "WhiteSpace" && depth === 0) { if (point === "rx" || point === "ry") { // Whitespace signifying end of rx/ry const node = this.#createNode( @@ -1256,7 +1305,7 @@ class OutputParser { coord = ""; depth = 0; } - } else if (token.tokenType === "ident" && token.text === "at") { + } else if (token.tokenType === "Ident" && token.text === "at") { // "at"; Add radius to container if not already done so if (point === "ry" && coord) { const node = this.#createNode( @@ -1277,10 +1326,10 @@ class OutputParser { coord = ""; depth = 0; } else if ( - token.tokenType === "number" || - token.tokenType === "dimension" || - token.tokenType === "percentage" || - token.tokenType === "function" + token.tokenType === "Number" || + token.tokenType === "Dimension" || + token.tokenType === "Percentage" || + token.tokenType === "Function" ) { if (point === "rx" && coord && depth === 0) { // Radius coords don't require whitespace between x/y. @@ -1313,7 +1362,7 @@ class OutputParser { } coord += coords.substring(token.startOffset, token.endOffset); - if (token.tokenType === "function") { + if (token.tokenType === "Function") { depth++; } } else { @@ -1366,7 +1415,7 @@ class OutputParser { // eslint-disable-next-line complexity #addInsetPointNodes(coords, container) { const insetPoints = ["top", "right", "bottom", "left"]; - const tokenStream = getCSSLexer(coords); + const tokenStream = new InspectorCSSParserWrapper(coords); let token = tokenStream.nextToken(); let depth = 0; let coord = ""; @@ -1383,16 +1432,16 @@ class OutputParser { if (round) { // Everything that comes after "round" should just be plain text otherText[i].push(coords.substring(token.startOffset, token.endOffset)); - } else if (token.tokenType === "symbol" && token.text === "(") { + } else if (token.tokenType === "ParenthesisBlock") { depth++; coord += coords.substring(token.startOffset, token.endOffset); - } else if (token.tokenType === "symbol" && token.text === ")") { + } else if (token.tokenType === "CloseParenthesis") { depth--; coord += coords.substring(token.startOffset, token.endOffset); - } else if (token.tokenType === "whitespace" && coord === "") { + } else if (token.tokenType === "WhiteSpace" && coord === "") { // Whitespace at beginning of coord; add to container otherText[i].push(coords.substring(token.startOffset, token.endOffset)); - } else if (token.tokenType === "whitespace" && depth === 0) { + } else if (token.tokenType === "WhiteSpace" && depth === 0) { // Whitespace signifying end of coord; create node and push to nodes const node = this.#createNode( "span", @@ -1407,10 +1456,10 @@ class OutputParser { otherText[i] = [coords.substring(token.startOffset, token.endOffset)]; depth = 0; } else if ( - token.tokenType === "number" || - token.tokenType === "dimension" || - token.tokenType === "percentage" || - token.tokenType === "function" + token.tokenType === "Number" || + token.tokenType === "Dimension" || + token.tokenType === "Percentage" || + token.tokenType === "Function" ) { if (coord && depth === 0) { // Inset coords don't require whitespace between each coord. @@ -1428,10 +1477,10 @@ class OutputParser { } coord += coords.substring(token.startOffset, token.endOffset); - if (token.tokenType === "function") { + if (token.tokenType === "Function") { depth++; } - } else if (token.tokenType === "ident" && token.text === "round") { + } else if (token.tokenType === "Ident" && token.text === "round") { if (coord && depth === 0) { // Whitespace is not necessary before "round"; create a new node for the coord const node = this.#createNode( @@ -1730,13 +1779,15 @@ class OutputParser { */ #sanitizeURL(url) { // Re-lex the URL and add any needed termination characters. - const urlTokenizer = getCSSLexer(url); + const urlTokenizer = new InspectorCSSParserWrapper(url, { + trackEOFChars: true, + }); // Just read until EOF; there will only be a single token. while (urlTokenizer.nextToken()) { // Nothing. } - return urlTokenizer.performEOFFixup(url, true); + return urlTokenizer.performEOFFixup(url); } /** @@ -1756,14 +1807,7 @@ class OutputParser { // 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. - const urlParts = - /^(url\([ \t\r\n\f]*(["']?))(.*?)(\2[ \t\r\n\f]*\))$/i.exec(match); + const urlParts = URL_REGEX.exec(match); // Bail out if that didn't match anything. if (!urlParts) { @@ -1771,7 +1815,7 @@ class OutputParser { return; } - const [, leader, , body, trailer] = urlParts; + const { leader, body, trailer } = urlParts.groups; this.#appendTextNode(leader); diff --git a/devtools/client/shared/screenshot.js b/devtools/client/shared/screenshot.js index ca746f66e7..4031708293 100644 --- a/devtools/client/shared/screenshot.js +++ b/devtools/client/shared/screenshot.js @@ -327,9 +327,9 @@ function saveToClipboard(base64URI) { let _outputDirectory = null; /** - * Returns the default directory for screenshots. - * If a specific directory for screenshots is not defined, - * it falls back to the system downloads directory. + * Returns the default directory for DevTools screenshots. + * For consistency with the Firefox Screenshots feature, this will default to + * the preferred downloads directory. * * @return {Promise} Resolves the path as a string */ @@ -338,13 +338,7 @@ async function getOutputDirectory() { return _outputDirectory; } - try { - // This will throw if there is not a screenshot directory set for the platform - _outputDirectory = Services.dirsvc.get("Scrnshts", Ci.nsIFile).path; - } catch (e) { - _outputDirectory = await lazy.Downloads.getPreferredDownloadsDirectory(); - } - + _outputDirectory = await lazy.Downloads.getPreferredDownloadsDirectory(); return _outputDirectory; } diff --git a/devtools/client/shared/sourceeditor/css-autocompleter.js b/devtools/client/shared/sourceeditor/css-autocompleter.js index 7db3cbbc0f..4266bd02b8 100644 --- a/devtools/client/shared/sourceeditor/css-autocompleter.js +++ b/devtools/client/shared/sourceeditor/css-autocompleter.js @@ -28,15 +28,14 @@ const { * The file 'css-parsing-utils' helps to convert the CSS into meaningful tokens, * each having a certain type associated with it. These tokens help us to figure * out the currently edited word and to write a CSS state machine to figure out - * what the user is currently editing. By that, I mean, whether he is editing a - * selector or a property or a value, or even fine grained information like an - * id in the selector. + * what the user is currently editing (e.g. a selector or a property or a value, + * or even fine grained information like an id in the selector). * * The `resolveState` method iterated over the tokens spitted out by the * tokenizer, using switch cases, follows a state machine logic and finally * figures out these informations: * - The state of the CSS at the cursor (one out of CSS_STATES) - * - The current token that is being edited `cmpleting` + * - The current token that is being edited `completing` * - If the state is "selector", the selector state (one of SELECTOR_STATES) * - If the state is "selector", the current selector till the cursor * - If the state is "value", the corresponding property name @@ -214,30 +213,26 @@ CSSCompleter.prototype = { // From CSS_STATES.property, we can either go to CSS_STATES.value // state when we hit the first ':' or CSS_STATES.selector if "}" is // reached. - if (token.tokenType === "symbol") { - switch (token.text) { - case ":": - scopeStack.push(":"); - if (tokens[cursor - 2].tokenType != "whitespace") { - propertyName = tokens[cursor - 2].text; - } else { - propertyName = tokens[cursor - 3].text; - } - _state = CSS_STATES.value; - break; + if (token.tokenType === "Colon") { + scopeStack.push(":"); + if (tokens[cursor - 2].tokenType != "WhiteSpace") { + propertyName = tokens[cursor - 2].text; + } else { + propertyName = tokens[cursor - 3].text; + } + _state = CSS_STATES.value; + } - case "}": - if (/[{f]/.test(peek(scopeStack))) { - const popped = scopeStack.pop(); - if (popped == "f") { - _state = CSS_STATES.frame; - } else { - selector = ""; - selectors = []; - _state = CSS_STATES.null; - } - } - break; + if (token.tokenType === "CloseCurlyBracket") { + if (/[{f]/.test(peek(scopeStack))) { + const popped = scopeStack.pop(); + if (popped == "f") { + _state = CSS_STATES.frame; + } else { + selector = ""; + selectors = []; + _state = CSS_STATES.null; + } } } break; @@ -245,31 +240,27 @@ CSSCompleter.prototype = { case CSS_STATES.value: // From CSS_STATES.value, we can go to one of CSS_STATES.property, // CSS_STATES.frame, CSS_STATES.selector and CSS_STATES.null - if (token.tokenType === "symbol") { - switch (token.text) { - case ";": - if (/[:]/.test(peek(scopeStack))) { - scopeStack.pop(); - _state = CSS_STATES.property; - } - break; + if (token.tokenType === "Semicolon") { + if (/[:]/.test(peek(scopeStack))) { + scopeStack.pop(); + _state = CSS_STATES.property; + } + } - case "}": - if (peek(scopeStack) == ":") { - scopeStack.pop(); - } + if (token.tokenType === "CloseCurlyBracket") { + if (peek(scopeStack) == ":") { + scopeStack.pop(); + } - if (/[{f]/.test(peek(scopeStack))) { - const popped = scopeStack.pop(); - if (popped == "f") { - _state = CSS_STATES.frame; - } else { - selector = ""; - selectors = []; - _state = CSS_STATES.null; - } - } - break; + if (/[{f]/.test(peek(scopeStack))) { + const popped = scopeStack.pop(); + if (popped == "f") { + _state = CSS_STATES.frame; + } else { + selector = ""; + selectors = []; + _state = CSS_STATES.null; + } } } break; @@ -277,7 +268,7 @@ CSSCompleter.prototype = { case CSS_STATES.selector: // From CSS_STATES.selector, we can only go to CSS_STATES.property // when we hit "{" - if (token.tokenType === "symbol" && token.text == "{") { + if (token.tokenType === "CurlyBracketBlock") { scopeStack.push("{"); _state = CSS_STATES.property; selectors.push(selector); @@ -290,74 +281,87 @@ CSSCompleter.prototype = { case SELECTOR_STATES.class: case SELECTOR_STATES.tag: switch (token.tokenType) { - case "hash": - case "id": + case "Hash": + case "IDHash": selectorState = SELECTOR_STATES.id; - selector += "#" + token.text; + selector += token.text; break; - case "symbol": + case "Delim": if (token.text == ".") { selectorState = SELECTOR_STATES.class; selector += "."; if ( cursor <= tokIndex && - tokens[cursor].tokenType == "ident" + tokens[cursor].tokenType == "Ident" ) { token = tokens[cursor++]; selector += token.text; } } else if (token.text == "#") { + // Lonely # char, that doesn't produce a Hash nor IDHash selectorState = SELECTOR_STATES.id; selector += "#"; - } else if (/[>~+]/.test(token.text)) { + } else if ( + token.text == "+" || + token.text == "~" || + token.text == ">" + ) { selectorState = SELECTOR_STATES.null; selector += token.text; - } else if (token.text == ",") { - selectorState = SELECTOR_STATES.null; - selectors.push(selector); - selector = ""; - } else if (token.text == ":") { - selectorState = SELECTOR_STATES.pseudo; - selector += ":"; - if (cursor > tokIndex) { - break; - } + } + break; - token = tokens[cursor++]; - switch (token.tokenType) { - case "function": - if (token.text == "not") { - selectorBeforeNot = selector; - selector = ""; - scopeStack.push("("); - } else { - selector += token.text + "("; - } - selectorState = SELECTOR_STATES.null; - break; - - case "ident": + case "Comma": + selectorState = SELECTOR_STATES.null; + selectors.push(selector); + selector = ""; + break; + + case "Colon": + selectorState = SELECTOR_STATES.pseudo; + selector += ":"; + if (cursor > tokIndex) { + break; + } + + token = tokens[cursor++]; + switch (token.tokenType) { + case "Function": + if (token.value == "not") { + selectorBeforeNot = selector; + selector = ""; + scopeStack.push("("); + } else { selector += token.text; - break; - } - } else if (token.text == "[") { - selectorState = SELECTOR_STATES.attribute; - scopeStack.push("["); - selector += "["; - } else if (token.text == ")") { - if (peek(scopeStack) == "(") { - scopeStack.pop(); - selector = selectorBeforeNot + "not(" + selector + ")"; - selectorBeforeNot = null; - } else { - selector += ")"; - } - selectorState = SELECTOR_STATES.null; + } + selectorState = SELECTOR_STATES.null; + break; + + case "Ident": + selector += token.text; + break; + } + break; + + case "SquareBracketBlock": + selectorState = SELECTOR_STATES.attribute; + scopeStack.push("["); + selector += "["; + break; + + case "CloseParenthesis": + if (peek(scopeStack) == "(") { + scopeStack.pop(); + selector = selectorBeforeNot + "not(" + selector + ")"; + selectorBeforeNot = null; + } else { + selector += ")"; } + selectorState = SELECTOR_STATES.null; break; - case "whitespace": + case "WhiteSpace": selectorState = SELECTOR_STATES.null; selector && (selector += " "); break; @@ -369,81 +373,94 @@ CSSCompleter.prototype = { // SELECTOR_STATES.id, SELECTOR_STATES.class or // SELECTOR_STATES.tag switch (token.tokenType) { - case "hash": - case "id": + case "Hash": + case "IDHash": selectorState = SELECTOR_STATES.id; - selector += "#" + token.text; + selector += token.text; break; - case "ident": + case "Ident": selectorState = SELECTOR_STATES.tag; selector += token.text; break; - case "symbol": + case "Delim": if (token.text == ".") { selectorState = SELECTOR_STATES.class; selector += "."; if ( cursor <= tokIndex && - tokens[cursor].tokenType == "ident" + tokens[cursor].tokenType == "Ident" ) { token = tokens[cursor++]; selector += token.text; } } else if (token.text == "#") { + // Lonely # char, that doesn't produce a Hash nor IDHash selectorState = SELECTOR_STATES.id; selector += "#"; } else if (token.text == "*") { selectorState = SELECTOR_STATES.tag; selector += "*"; - } else if (/[>~+]/.test(token.text)) { + } else if ( + token.text == "+" || + token.text == "~" || + token.text == ">" + ) { selector += token.text; - } else if (token.text == ",") { - selectorState = SELECTOR_STATES.null; - selectors.push(selector); - selector = ""; - } else if (token.text == ":") { - selectorState = SELECTOR_STATES.pseudo; - selector += ":"; - if (cursor > tokIndex) { - break; - } + } + break; - token = tokens[cursor++]; - switch (token.tokenType) { - case "function": - if (token.text == "not") { - selectorBeforeNot = selector; - selector = ""; - scopeStack.push("("); - } else { - selector += token.text + "("; - } - selectorState = SELECTOR_STATES.null; - break; - - case "ident": + case "Comma": + selectorState = SELECTOR_STATES.null; + selectors.push(selector); + selector = ""; + break; + + case "Colon": + selectorState = SELECTOR_STATES.pseudo; + selector += ":"; + if (cursor > tokIndex) { + break; + } + + token = tokens[cursor++]; + switch (token.tokenType) { + case "Function": + if (token.value == "not") { + selectorBeforeNot = selector; + selector = ""; + scopeStack.push("("); + } else { selector += token.text; - break; - } - } else if (token.text == "[") { - selectorState = SELECTOR_STATES.attribute; - scopeStack.push("["); - selector += "["; - } else if (token.text == ")") { - if (peek(scopeStack) == "(") { - scopeStack.pop(); - selector = selectorBeforeNot + "not(" + selector + ")"; - selectorBeforeNot = null; - } else { - selector += ")"; - } - selectorState = SELECTOR_STATES.null; + } + selectorState = SELECTOR_STATES.null; + break; + + case "Ident": + selector += token.text; + break; } break; - case "whitespace": + case "SquareBracketBlock": + selectorState = SELECTOR_STATES.attribute; + scopeStack.push("["); + selector += "["; + break; + + case "CloseParenthesis": + if (peek(scopeStack) == "(") { + scopeStack.pop(); + selector = selectorBeforeNot + "not(" + selector + ")"; + selectorBeforeNot = null; + } else { + selector += ")"; + } + selectorState = SELECTOR_STATES.null; + break; + + case "WhiteSpace": selector && (selector += " "); break; } @@ -451,46 +468,55 @@ CSSCompleter.prototype = { case SELECTOR_STATES.pseudo: switch (token.tokenType) { - case "symbol": - if (/[>~+]/.test(token.text)) { + case "Delim": + if ( + token.text == "+" || + token.text == "~" || + token.text == ">" + ) { selectorState = SELECTOR_STATES.null; selector += token.text; - } else if (token.text == ",") { - selectorState = SELECTOR_STATES.null; - selectors.push(selector); - selector = ""; - } else if (token.text == ":") { - selectorState = SELECTOR_STATES.pseudo; - selector += ":"; - if (cursor > tokIndex) { - break; - } + } + break; + + case "Comma": + selectorState = SELECTOR_STATES.null; + selectors.push(selector); + selector = ""; + break; + + case "Colon": + selectorState = SELECTOR_STATES.pseudo; + selector += ":"; + if (cursor > tokIndex) { + break; + } - token = tokens[cursor++]; - switch (token.tokenType) { - case "function": - if (token.text == "not") { - selectorBeforeNot = selector; - selector = ""; - scopeStack.push("("); - } else { - selector += token.text + "("; - } - selectorState = SELECTOR_STATES.null; - break; - - case "ident": + token = tokens[cursor++]; + switch (token.tokenType) { + case "Function": + if (token.value == "not") { + selectorBeforeNot = selector; + selector = ""; + scopeStack.push("("); + } else { selector += token.text; - break; - } - } else if (token.text == "[") { - selectorState = SELECTOR_STATES.attribute; - scopeStack.push("["); - selector += "["; + } + selectorState = SELECTOR_STATES.null; + break; + + case "Ident": + selector += token.text; + break; } break; + case "SquareBracketBlock": + selectorState = SELECTOR_STATES.attribute; + scopeStack.push("["); + selector += "["; + break; - case "whitespace": + case "WhiteSpace": selectorState = SELECTOR_STATES.null; selector && (selector += " "); break; @@ -499,29 +525,40 @@ CSSCompleter.prototype = { case SELECTOR_STATES.attribute: switch (token.tokenType) { - case "symbol": - if (/[~|^$*]/.test(token.text)) { - selector += token.text; - token = tokens[cursor++]; - } else if (token.text == "=") { + case "IncludeMatch": + case "DashMatch": + case "PrefixMatch": + case "IncludeSuffixMatchMatch": + case "SubstringMatch": + selector += token.text; + token = tokens[cursor++]; + break; + + case "Delim": + if (token.text == "=") { selectorState = SELECTOR_STATES.value; selector += token.text; - } else if (token.text == "]") { - if (peek(scopeStack) == "[") { - scopeStack.pop(); - } + } + break; - selectorState = SELECTOR_STATES.null; - selector += "]"; + case "CloseSquareBracket": + if (peek(scopeStack) == "[") { + scopeStack.pop(); } + + selectorState = SELECTOR_STATES.null; + selector += "]"; break; - case "ident": - case "string": + case "Ident": selector += token.text; break; - case "whitespace": + case "QuotedString": + selector += token.value; + break; + + case "WhiteSpace": selector && (selector += " "); break; } @@ -529,23 +566,24 @@ CSSCompleter.prototype = { case SELECTOR_STATES.value: switch (token.tokenType) { - case "string": - case "ident": + case "Ident": selector += token.text; break; - case "symbol": - if (token.text == "]") { - if (peek(scopeStack) == "[") { - scopeStack.pop(); - } + case "QuotedString": + selector += token.value; + break; - selectorState = SELECTOR_STATES.null; - selector += "]"; + case "CloseSquareBracket": + if (peek(scopeStack) == "[") { + scopeStack.pop(); } + + selectorState = SELECTOR_STATES.null; + selector += "]"; break; - case "whitespace": + case "WhiteSpace": selector && (selector += " "); break; } @@ -557,29 +595,30 @@ CSSCompleter.prototype = { // From CSS_STATES.null state, we can go to either CSS_STATES.media or // CSS_STATES.selector. switch (token.tokenType) { - case "hash": - case "id": + case "Hash": + case "IDHash": selectorState = SELECTOR_STATES.id; - selector = "#" + token.text; + selector = token.text; _state = CSS_STATES.selector; break; - case "ident": + case "Ident": selectorState = SELECTOR_STATES.tag; selector = token.text; _state = CSS_STATES.selector; break; - case "symbol": + case "Delim": if (token.text == ".") { selectorState = SELECTOR_STATES.class; selector = "."; _state = CSS_STATES.selector; - if (cursor <= tokIndex && tokens[cursor].tokenType == "ident") { + if (cursor <= tokIndex && tokens[cursor].tokenType == "Ident") { token = tokens[cursor++]; selector += token.text; } } else if (token.text == "#") { + // Lonely # char, that doesn't produce a Hash nor IDHash selectorState = SELECTOR_STATES.id; selector = "#"; _state = CSS_STATES.selector; @@ -587,45 +626,52 @@ CSSCompleter.prototype = { selectorState = SELECTOR_STATES.tag; selector = "*"; _state = CSS_STATES.selector; - } else if (token.text == ":") { - _state = CSS_STATES.selector; - selectorState = SELECTOR_STATES.pseudo; - selector += ":"; - if (cursor > tokIndex) { - break; - } + } + break; - token = tokens[cursor++]; - switch (token.tokenType) { - case "function": - if (token.text == "not") { - selectorBeforeNot = selector; - selector = ""; - scopeStack.push("("); - } else { - selector += token.text + "("; - } - selectorState = SELECTOR_STATES.null; - break; + case "Colon": + _state = CSS_STATES.selector; + selectorState = SELECTOR_STATES.pseudo; + selector += ":"; + if (cursor > tokIndex) { + break; + } - case "ident": + token = tokens[cursor++]; + switch (token.tokenType) { + case "Function": + if (token.value == "not") { + selectorBeforeNot = selector; + selector = ""; + scopeStack.push("("); + } else { selector += token.text; - break; - } - } else if (token.text == "[") { - _state = CSS_STATES.selector; - selectorState = SELECTOR_STATES.attribute; - scopeStack.push("["); - selector += "["; - } else if (token.text == "}") { - if (peek(scopeStack) == "@m") { - scopeStack.pop(); - } + } + selectorState = SELECTOR_STATES.null; + break; + + case "Ident": + selector += token.text; + break; + } + break; + + case "CloseSquareBracket": + _state = CSS_STATES.selector; + selectorState = SELECTOR_STATES.attribute; + scopeStack.push("["); + selector += "["; + break; + + case "CurlyBracketBlock": + if (peek(scopeStack) == "@m") { + scopeStack.pop(); } break; - case "at": - _state = token.text.startsWith("m") + case "AtKeyword": + // XXX: We should probably handle other at-rules (@container, @property, …) + _state = token.value.startsWith("m") ? CSS_STATES.media : CSS_STATES.keyframes; break; @@ -635,7 +681,7 @@ CSSCompleter.prototype = { case CSS_STATES.media: // From CSS_STATES.media, we can only go to CSS_STATES.null state when // we hit the first '{' - if (token.tokenType == "symbol" && token.text == "{") { + if (token.tokenType == "CurlyBracketBlock") { scopeStack.push("@m"); _state = CSS_STATES.null; } @@ -644,7 +690,7 @@ CSSCompleter.prototype = { case CSS_STATES.keyframes: // From CSS_STATES.keyframes, we can only go to CSS_STATES.frame state // when we hit the first '{' - if (token.tokenType == "symbol" && token.text == "{") { + if (token.tokenType == "CurlyBracketBlock") { scopeStack.push("@k"); _state = CSS_STATES.frame; } @@ -654,17 +700,15 @@ CSSCompleter.prototype = { // From CSS_STATES.frame, we can either go to CSS_STATES.property // state when we hit the first '{' or to CSS_STATES.selector when we // hit '}' - if (token.tokenType == "symbol") { - if (token.text == "{") { - scopeStack.push("f"); - _state = CSS_STATES.property; - } else if (token.text == "}") { - if (peek(scopeStack) == "@k") { - scopeStack.pop(); - } - - _state = CSS_STATES.null; + if (token.tokenType == "CurlyBracketBlock") { + scopeStack.push("f"); + _state = CSS_STATES.property; + } else if (token.tokenType == "CloseCurlyBracket") { + if (peek(scopeStack) == "@k") { + scopeStack.pop(); } + + _state = CSS_STATES.null; } break; } @@ -688,6 +732,8 @@ CSSCompleter.prototype = { this.nullStates.push([tokenLine, tokenCh, [...scopeStack]]); } } + // ^ while loop end + this.state = _state; this.propertyName = _state == CSS_STATES.value ? propertyName : null; this.selectorState = _state == CSS_STATES.selector ? selectorState : null; @@ -701,10 +747,18 @@ CSSCompleter.prototype = { } this.selectors = selectors; - if (token && token.tokenType != "whitespace") { + if (token && token.tokenType != "WhiteSpace") { let text; - if (token.tokenType == "dimension" || !token.text) { + if (token.tokenType == "Dimension" || !token.text) { text = source.substring(token.startOffset, token.endOffset); + } else if ( + token.tokenType === "IDHash" || + token.tokenType === "Hash" || + token.tokenType === "AtKeyword" || + token.tokenType === "Function" || + token.tokenType === "QuotedString" + ) { + text = token.value; } else { text = token.text; } @@ -1047,10 +1101,10 @@ CSSCompleter.prototype = { } let prevToken = undefined; - const tokens = cssTokenizer(lineText); + const tokensIterator = cssTokenizer(lineText); let found = false; const ech = line == caret.line ? caret.ch : 0; - for (let token of tokens) { + for (let token of tokensIterator) { // If the line is completely spaces, handle it differently if (lineText.trim() == "") { limitedSource += lineText; @@ -1061,8 +1115,8 @@ CSSCompleter.prototype = { ); } - // Whitespace cannot change state. - if (token.tokenType == "whitespace") { + // WhiteSpace cannot change state. + if (token.tokenType == "WhiteSpace") { prevToken = token; continue; } @@ -1072,7 +1126,7 @@ CSSCompleter.prototype = { ch: token.endOffset + ech, }); if (check(forwState)) { - if (prevToken && prevToken.tokenType == "whitespace") { + if (prevToken && prevToken.tokenType == "WhiteSpace") { token = prevToken; } location = { @@ -1123,8 +1177,8 @@ CSSCompleter.prototype = { limitedSource = limitedSource.slice(0, -1 * length); } - // Whitespace cannot change state. - if (token.tokenType == "whitespace") { + // WhiteSpace cannot change state. + if (token.tokenType == "WhiteSpace") { continue; } @@ -1133,7 +1187,7 @@ CSSCompleter.prototype = { ch: token.startOffset, }); if (check(backState)) { - if (tokens[i + 1] && tokens[i + 1].tokenType == "whitespace") { + if (tokens[i + 1] && tokens[i + 1].tokenType == "WhiteSpace") { token = tokens[i + 1]; } location = { diff --git a/devtools/client/shared/sourceeditor/editor.js b/devtools/client/shared/sourceeditor/editor.js index 3487acffa4..90e9f6e373 100644 --- a/devtools/client/shared/sourceeditor/editor.js +++ b/devtools/client/shared/sourceeditor/editor.js @@ -168,6 +168,7 @@ class Editor extends EventEmitter { #win; #lineGutterMarkers = new Map(); #lineContentMarkers = new Map(); + #lineContentEventHandlers = {}; #updateListener = null; @@ -676,7 +677,9 @@ class Editor extends EventEmitter { } }), lineNumberMarkersCompartment.of([]), - lineContentMarkerCompartment.of(this.#lineContentMarkersExtension([])), + lineContentMarkerCompartment.of( + this.#lineContentMarkersExtension({ markers: [] }) + ), // keep last so other extension take precedence codemirror.minimalSetup, ]; @@ -696,29 +699,51 @@ class Editor extends EventEmitter { /** * This creates the extension used to manage the rendering of markers * for in editor line content. - * @param {Array} markers - The current list of markers + * @param {Array} markers - The current list of markers + * @param {Object} domEventHandlers - A dictionary of handlers for the DOM events + * See https://codemirror.net/docs/ref/#view.PluginSpec.eventHandlers * @returns {Array} showLineContentDecorations - An extension which is an array containing the view * which manages the rendering of the line content markers. */ - #lineContentMarkersExtension(markers) { + #lineContentMarkersExtension({ markers, domEventHandlers }) { const { - codemirrorView: { Decoration, ViewPlugin }, - codemirrorState: { RangeSetBuilder }, + codemirrorView: { Decoration, ViewPlugin, WidgetType }, + codemirrorState: { RangeSetBuilder, RangeSet }, } = this.#CodeMirror6; + class LineContentWidget extends WidgetType { + constructor(line, createElementNode) { + super(); + this.toDOM = () => createElementNode(line); + } + } + // Build and return the decoration set function buildDecorations(view) { + if (!markers) { + return RangeSet.empty; + } const builder = new RangeSetBuilder(); for (const { from, to } of view.visibleRanges) { for (let pos = from; pos <= to; ) { const line = view.state.doc.lineAt(pos); - for (const { lineClassName, condition } of markers) { - if (condition(line.number)) { - builder.add( - line.from, - line.from, - Decoration.line({ class: lineClassName }) - ); + for (const marker of markers) { + if (marker.condition(line.number)) { + if (marker.lineClassName) { + const classDecoration = Decoration.line({ + class: marker.lineClassName, + }); + builder.add(line.from, line.from, classDecoration); + } + if (marker.createLineElementNode) { + const nodeDecoration = Decoration.widget({ + widget: new LineContentWidget( + line.number, + marker.createLineElementNode + ), + }); + builder.add(line.to, line.to, nodeDecoration); + } } } pos = line.to + 1; @@ -727,9 +752,9 @@ class Editor extends EventEmitter { return builder.finish(); } - // The view which handles rendering and updating the + // The view which handles events, rendering and updating the // markers decorations - const showLineContentDecorations = ViewPlugin.fromClass( + const lineContentMarkersView = ViewPlugin.fromClass( class { decorations; constructor(view) { @@ -741,10 +766,46 @@ class Editor extends EventEmitter { } } }, - { decorations: v => v.decorations } + { + decorations: v => v.decorations, + eventHandlers: domEventHandlers || this.#lineContentEventHandlers, + } ); - return [showLineContentDecorations]; + return [lineContentMarkersView]; + } + + /** + * + * @param {Object} domEventHandlers - A dictionary of handlers for the DOM events + */ + setContentEventListeners(domEventHandlers) { + const cm = editors.get(this); + + for (const eventName in domEventHandlers) { + const handler = domEventHandlers[eventName]; + domEventHandlers[eventName] = (event, editor) => { + // Wait a cycle so the codemirror updates to the current cursor position, + // information, TODO: Currently noticed this issue with CM6, not ideal but should + // investigate further Bug 1890895. + event.target.ownerGlobal.setTimeout(() => { + const view = editor.viewState; + const head = view.state.selection.main.head; + const cursor = view.state.doc.lineAt(head); + const column = head - cursor.from; + handler(event, view, cursor.number, column); + }, 0); + }; + } + + // Cache the handlers related to the editor content + this.#lineContentEventHandlers = domEventHandlers; + + cm.dispatch({ + effects: this.#compartments.lineContentMarkerCompartment.reconfigure( + this.#lineContentMarkersExtension({ domEventHandlers }) + ), + }); } /** @@ -754,6 +815,9 @@ class Editor extends EventEmitter { * @property {string} marker.lineClassName - The css class to add to the line * @property {function} marker.condition - The condition that decides if the marker/class gets added or removed. * The line is passed as an argument. + * @property {function} marker.createLineElementNode - This should return the DOM element which + * is used for the marker. The line number is passed as a parameter. + * This is optional. */ setLineContentMarker(marker) { const cm = editors.get(this); @@ -761,9 +825,9 @@ class Editor extends EventEmitter { cm.dispatch({ effects: this.#compartments.lineContentMarkerCompartment.reconfigure( - this.#lineContentMarkersExtension( - Array.from(this.#lineContentMarkers.values()) - ) + this.#lineContentMarkersExtension({ + markers: Array.from(this.#lineContentMarkers.values()), + }) ), }); } @@ -778,9 +842,9 @@ class Editor extends EventEmitter { cm.dispatch({ effects: this.#compartments.lineContentMarkerCompartment.reconfigure( - this.#lineContentMarkersExtension( - Array.from(this.#lineContentMarkers.values()) - ) + this.#lineContentMarkersExtension({ + markers: Array.from(this.#lineContentMarkers.values()), + }) ), }); } @@ -869,31 +933,31 @@ class Editor extends EventEmitter { // (representing the lines in the current viewport) and generate a new rangeset for updating the line gutter // based on the conditions defined in the markers(for each line) provided. const builder = new RangeSetBuilder(); - for (const { from, to } of cm.visibleRanges) { - for (let pos = from; pos <= to; ) { - const line = cm.state.doc.lineAt(pos); - for (const { - lineClassName, - condition, - createLineElementNode, - } of markers) { - if (typeof condition !== "function") { - throw new Error("The `condition` is not a valid function"); - } - if (condition(line.number)) { - builder.add( - line.from, - line.to, - new LineGutterMarker( - lineClassName, - line.number, - createLineElementNode - ) - ); - } + const { from, to } = cm.viewport; + let pos = from; + while (pos <= to) { + const line = cm.state.doc.lineAt(pos); + for (const { + lineClassName, + condition, + createLineElementNode, + } of markers) { + if (typeof condition !== "function") { + throw new Error("The `condition` is not a valid function"); + } + if (condition(line.number)) { + builder.add( + line.from, + line.to, + new LineGutterMarker( + lineClassName, + line.number, + createLineElementNode + ) + ); } - pos = line.to + 1; } + pos = line.to + 1; } // To update the state with the newly generated marker range set, a dispatch is called on the view @@ -906,6 +970,61 @@ class Editor extends EventEmitter { }); } + /** + * Gets the position information for the current selection + * @returns {Object} cursor - The location information for the current selection + * cursor.from - An object with the starting line / column of the selection + * cursor.to - An object with the end line / column of the selection + */ + getSelectionCursor() { + const cm = editors.get(this); + if (this.config.cm6) { + const selection = cm.state.selection.ranges[0]; + const lineFrom = cm.state.doc.lineAt(selection.from); + const lineTo = cm.state.doc.lineAt(selection.to); + return { + from: { + line: lineFrom.number, + ch: selection.from - lineFrom.from, + }, + to: { + line: lineTo.number, + ch: selection.to - lineTo.from, + }, + }; + } + return { + from: cm.getCursor("from"), + to: cm.getCursor("to"), + }; + } + + /** + * Gets the text content for the current selection + * @returns {String} + */ + getSelectedText() { + const cm = editors.get(this); + if (this.config.cm6) { + const selection = cm.state.selection.ranges[0]; + return cm.state.doc.sliceString(selection.from, selection.to); + } + return cm.getSelection().trim(); + } + + /** + * Check that text is selected + * @returns {Boolean} + */ + isTextSelected() { + const cm = editors.get(this); + if (this.config.cm6) { + const selection = cm.state.selection.ranges[0]; + return selection.from !== selection.to; + } + return cm.somethingSelected(); + } + /** * Returns a boolean indicating whether the editor is ready to * use. Use appendTo(el).then(() => {}) for most cases @@ -1859,6 +1978,119 @@ class Editor extends EventEmitter { }); } + /** + * This checks if the specified position (top/left) is within the current viewpport + * bounds. it helps determine is scrolling should happen. + * @param {Object} cm - The codemirror instance + * @param {Number} line - The line in the source + * @param {Number} column - The column in the source + * @returns {Boolean} + */ + #isVisible(cm, line, column) { + let inXView, inYView; + + function withinBounds(x, min, max) { + return x >= min && x <= max; + } + + if (this.config.cm6) { + const pos = this.#posToOffset(cm.state.doc, line, column); + const coords = pos && cm.coordsAtPos(pos); + if (!coords) { + return false; + } + const { scrollTop, scrollLeft, clientHeight, clientWidth } = cm.scrollDOM; + + inXView = withinBounds(coords.left, scrollLeft, scrollLeft + clientWidth); + inYView = withinBounds(coords.top, scrollTop, scrollTop + clientHeight); + } else { + const { top, left } = cm.charCoords({ line, ch: column }, "local"); + const scrollArea = cm.getScrollInfo(); + const charWidth = cm.defaultCharWidth(); + const fontHeight = cm.defaultTextHeight(); + const { scrollTop, scrollLeft } = cm.doc; + + inXView = withinBounds( + left, + scrollLeft, + // Note: 30 might relate to the margin on one of the scroll bar elements. + // See comment https://github.com/firefox-devtools/debugger/pull/5182#discussion_r163439209 + scrollLeft + (scrollArea.clientWidth - 30) - charWidth + ); + inYView = withinBounds( + top, + scrollTop, + scrollTop + scrollArea.clientHeight - fontHeight + ); + } + return inXView && inYView; + } + + /** + * Converts line/col to CM6 offset position + * @param {Object} doc - the codemirror document + * @param {Number} line - The line in the source + * @param {Number} col - The column in the source + * @returns {Number} + */ + #posToOffset(doc, line, col) { + if (!this.config.cm6) { + throw new Error("This function is only compatible with CM6"); + } + try { + const offset = doc.line(line); + return offset.from + col; + } catch (e) { + // Line likey does not exist in viewport yet + console.warn(e.message); + } + return null; + } + + /** + * Scrolls the editor to the specified line and column + * @param {Number} line - The line in the source + * @param {Number} column - The column in the source + */ + scrollTo(line, column) { + const cm = editors.get(this); + if (this.config.cm6) { + const { + codemirrorView: { EditorView }, + } = this.#CodeMirror6; + + if (!this.#isVisible(cm, line, column)) { + const offset = this.#posToOffset(cm.state.doc, line, column); + if (!offset) { + return; + } + cm.dispatch({ + effects: EditorView.scrollIntoView(offset, { + x: "nearest", + y: "center", + }), + }); + } + } else { + // For all cases where these are on the first line and column, + // avoid the possibly slow computation of cursor location on large bundles. + if (!line && !column) { + cm.scrollTo(0, 0); + return; + } + + const { top, left } = cm.charCoords({ line, ch: column }, "local"); + + if (!this.#isVisible(cm, line, column)) { + const scroller = cm.getScrollerElement(); + const centeredX = Math.max(left - scroller.offsetWidth / 2, 0); + const centeredY = Math.max(top - scroller.offsetHeight / 2, 0); + + cm.scrollTo(centeredX, centeredY); + } + } + } + /** * Extends an instance of the Editor object with additional * functions. Each function will be called with context as @@ -1901,6 +2133,8 @@ class Editor extends EventEmitter { this.#ownerDoc = null; this.#updateListener = null; this.#lineGutterMarkers.clear(); + this.#lineContentMarkers.clear(); + this.#lineContentEventHandlers = {}; if (this.#prefObserver) { this.#prefObserver.off(KEYMAP_PREF, this.setKeyMap); diff --git a/devtools/client/shared/test/browser_filter-editor-01.js b/devtools/client/shared/test/browser_filter-editor-01.js index 557a02857c..106f89dbc4 100644 --- a/devtools/client/shared/test/browser_filter-editor-01.js +++ b/devtools/client/shared/test/browser_filter-editor-01.js @@ -10,15 +10,14 @@ const { } = require("resource://devtools/client/shared/widgets/FilterWidget.js"); const TEST_URI = CHROME_URL_ROOT + "doc_filter-editor-01.html"; -const { getCSSLexer } = require("resource://devtools/shared/css/lexer.js"); // Verify that the given string consists of a valid CSS URL token. // Return true on success, false on error. function verifyURL(string) { - const lexer = getCSSLexer(string); + const lexer = new InspectorCSSParser(string); const token = lexer.nextToken(); - if (!token || token.tokenType !== "url") { + if (!token || token.tokenType !== "UnquotedUrl") { return false; } diff --git a/devtools/client/shared/test/browser_outputparser.js b/devtools/client/shared/test/browser_outputparser.js index 7e0c1b7e00..a2fcce8f78 100644 --- a/devtools/client/shared/test/browser_outputparser.js +++ b/devtools/client/shared/test/browser_outputparser.js @@ -700,6 +700,22 @@ function testParseVariable(doc, parser) { ")" + "", }, + { + text: "rgb(var(--not-seen), 0, 0)", + variables: {}, + expected: + // prettier-ignore + `rgb(` + + `` + + `var(` + + `` + + `--not-seen` + + `` + + `)` + + `` + + `, 0, 0` + + `)`, + }, ]; for (const test of TESTS) { diff --git a/devtools/client/shared/test/xpcshell/test_parseDeclarations.js b/devtools/client/shared/test/xpcshell/test_parseDeclarations.js index 593087d46b..373d5acc56 100644 --- a/devtools/client/shared/test/xpcshell/test_parseDeclarations.js +++ b/devtools/client/shared/test/xpcshell/test_parseDeclarations.js @@ -750,7 +750,7 @@ const TEST_DATA = [ expected: [ { name: "color", - value: "blue \\9 no_need", + value: "blue \\9 no\\_need", priority: "", offsets: [0, 23], declarationText: "color: blue \\9 no\\_need", @@ -1609,18 +1609,27 @@ function assertOutput(input, actual, expected) { "Check that the output item has the expected name, " + "value and priority" ); - Assert.equal(expected[i].name, actual[i].name); - Assert.equal(expected[i].value, actual[i].value); - Assert.equal(expected[i].priority, actual[i].priority); - deepEqual(expected[i].offsets, actual[i].offsets); + Assert.equal(actual[i].name, expected[i].name, "Expected name"); + Assert.equal(actual[i].value, expected[i].value, "Expected value"); + Assert.equal( + actual[i].priority, + expected[i].priority, + "Expected priority" + ); + deepEqual(actual[i].offsets, expected[i].offsets, "Expected offsets"); if ("commentOffsets" in expected[i]) { - deepEqual(expected[i].commentOffsets, actual[i].commentOffsets); + deepEqual( + actual[i].commentOffsets, + expected[i].commentOffsets, + "Expected commentOffsets" + ); } if (expected[i].declarationText) { Assert.equal( input.substring(expected[i].offsets[0], expected[i].offsets[1]), - expected[i].declarationText + expected[i].declarationText, + "Expected declarationText" ); } } diff --git a/devtools/client/shared/test/xpcshell/test_rewriteDeclarations.js b/devtools/client/shared/test/xpcshell/test_rewriteDeclarations.js index 228d2dc79d..9842db7c02 100644 --- a/devtools/client/shared/test/xpcshell/test_rewriteDeclarations.js +++ b/devtools/client/shared/test/xpcshell/test_rewriteDeclarations.js @@ -483,9 +483,7 @@ const TEST_DATA = [ enabled: true, }, expected: "something: \\\\;color: red;", - // The lexer rewrites the token before we see it. However this is - // so obscure as to be inconsequential. - changed: { 0: "\uFFFD\\" }, + changed: { 0: "\\\\" }, }, // Termination insertion corner case. diff --git a/devtools/client/shared/widgets/CubicBezierWidget.js b/devtools/client/shared/widgets/CubicBezierWidget.js index 39407d4711..df41949df5 100644 --- a/devtools/client/shared/widgets/CubicBezierWidget.js +++ b/devtools/client/shared/widgets/CubicBezierWidget.js @@ -31,7 +31,9 @@ const { PRESETS, DEFAULT_PRESET_CATEGORY, } = require("resource://devtools/client/shared/widgets/CubicBezierPresets.js"); -const { getCSSLexer } = require("resource://devtools/shared/css/lexer.js"); +const { + InspectorCSSParserWrapper, +} = require("resource://devtools/shared/css/lexer.js"); const XHTML_NS = "http://www.w3.org/1999/xhtml"; /** @@ -918,13 +920,13 @@ function parseTimingFunction(value) { return PREDEFINED[value]; } - const tokenStream = getCSSLexer(value); + const tokenStream = new InspectorCSSParserWrapper(value); const getNextToken = () => { while (true) { const token = tokenStream.nextToken(); if ( !token || - (token.tokenType !== "whitespace" && token.tokenType !== "comment") + (token.tokenType !== "WhiteSpace" && token.tokenType !== "Comment") ) { return token; } @@ -932,24 +934,20 @@ function parseTimingFunction(value) { }; let token = getNextToken(); - if (token.tokenType !== "function" || token.text !== "cubic-bezier") { + if (token.tokenType !== "Function" || token.value !== "cubic-bezier") { return undefined; } const result = []; for (let i = 0; i < 4; ++i) { token = getNextToken(); - if (!token || token.tokenType !== "number") { + if (!token || token.tokenType !== "Number") { return undefined; } result.push(token.number); token = getNextToken(); - if ( - !token || - token.tokenType !== "symbol" || - token.text !== (i == 3 ? ")" : ",") - ) { + if (!token || token.tokenType !== (i == 3 ? "CloseParenthesis" : "Comma")) { return undefined; } } diff --git a/devtools/client/shared/widgets/FilterWidget.js b/devtools/client/shared/widgets/FilterWidget.js index bb23bdfeca..487cc9ad0b 100644 --- a/devtools/client/shared/widgets/FilterWidget.js +++ b/devtools/client/shared/widgets/FilterWidget.js @@ -814,7 +814,7 @@ CSSFilterEditorWidget.prototype = { return; } - for (let { name, value, quote } of tokenizeFilterValue(cssValue)) { + for (let { name, value } of tokenizeFilterValue(cssValue)) { // If the specified value is invalid, replace it with the // default. if (name !== "url") { @@ -823,7 +823,7 @@ CSSFilterEditorWidget.prototype = { } } - this.add(name, value, quote, true); + this.add(name, value, true); } this.emit("updated", this.getCssValue()); @@ -838,9 +838,6 @@ CSSFilterEditorWidget.prototype = { * @param {String} value * value of the filter (e.g. 30px, 20%) * If this is |null|, then a default value may be supplied. - * @param {String} quote - * For a url filter, the quoting style. This can be a - * single quote, a double quote, or empty. * @return {Number} * The index of the new filter in the current list of filters * @param {Boolean} @@ -848,7 +845,7 @@ CSSFilterEditorWidget.prototype = { * you're calling add in a loop and wait to emit a single event after * the loop yourself, set this parameter to true. */ - add(name, value, quote, noEvent) { + add(name, value, noEvent) { const def = this._definition(name); if (!def) { return false; @@ -868,11 +865,6 @@ CSSFilterEditorWidget.prototype = { } else { value = def.range[0] + unitLabel; } - - if (name === "url") { - // Default quote. - quote = '"'; - } } let unit = def.type === "string" ? "" : (/[a-zA-Z%]+/.exec(value) || [])[0]; @@ -894,7 +886,7 @@ CSSFilterEditorWidget.prototype = { } } - const index = this.filters.push({ value, unit, name, quote }) - 1; + const index = this.filters.push({ value, unit, name }) - 1; if (!noEvent) { this.emit("updated", this.getCssValue()); } @@ -916,22 +908,12 @@ CSSFilterEditorWidget.prototype = { return null; } - // Just return the value+unit for non-url functions. - if (filter.name !== "url") { - return filter.value + filter.unit; + // Just return the value url functions. + if (filter.name === "url") { + return filter.value; } - // url values need to be quoted and escaped. - if (filter.quote === "'") { - return "'" + filter.value.replace(/\'/g, "\\'") + "'"; - } else if (filter.quote === '"') { - return '"' + filter.value.replace(/\"/g, '\\"') + '"'; - } - - // Unquoted. This approach might change the original input -- for - // example the original might be over-quoted. But, this is - // correct and probably good enough. - return filter.value.replace(/[\\ \t()"']/g, "\\$&"); + return filter.value + filter.unit; }, removeAt(index) { @@ -1051,27 +1033,34 @@ function tokenizeFilterValue(css) { for (const token of cssTokenizer(css)) { switch (state) { case "initial": - if (token.tokenType === "function") { - name = token.text; + if (token.tokenType === "Function") { + name = token.value; contents = ""; state = "function"; depth = 1; - } else if (token.tokenType === "url" || token.tokenType === "bad_url") { - // Extract the quoting style from the url. - const originalText = css.substring( - token.startOffset, - token.endOffset - ); - const [, quote] = /^url\([ \t\r\n\f]*(["']?)/i.exec(originalText); - - filters.push({ name: "url", value: token.text.trim(), quote }); + } else if ( + token.tokenType === "UnquotedUrl" || + token.tokenType === "BadUrl" + ) { + const url = token.text + .substring( + // token text starts with `url(` + 4, + // unquoted url also include the closing parenthesis + token.tokenType == "UnquotedUrl" + ? token.text.length - 1 + : undefined + ) + .trim(); + + filters.push({ name: "url", value: url }); // Leave state as "initial" because the URL token includes // the trailing close paren. } break; case "function": - if (token.tokenType === "symbol" && token.text === ")") { + if (token.tokenType === "CloseParenthesis") { --depth; if (depth === 0) { filters.push({ name, value: contents.trim() }); @@ -1081,8 +1070,8 @@ function tokenizeFilterValue(css) { } contents += css.substring(token.startOffset, token.endOffset); if ( - token.tokenType === "function" || - (token.tokenType === "symbol" && token.text === "(") + token.tokenType === "Function" || + token.tokenType === "Parenthesis" ) { ++depth; } diff --git a/devtools/client/shared/widgets/LinearEasingFunctionWidget.js b/devtools/client/shared/widgets/LinearEasingFunctionWidget.js index e6d2e604df..5ea3b33d15 100644 --- a/devtools/client/shared/widgets/LinearEasingFunctionWidget.js +++ b/devtools/client/shared/widgets/LinearEasingFunctionWidget.js @@ -9,7 +9,7 @@ */ const EventEmitter = require("devtools/shared/event-emitter"); -const { getCSSLexer } = require("devtools/shared/css/lexer"); +const { InspectorCSSParserWrapper } = require("devtools/shared/css/lexer"); const { throttle } = require("devtools/shared/throttle"); const XHTML_NS = "http://www.w3.org/1999/xhtml"; const SVG_NS = "http://www.w3.org/2000/svg"; @@ -578,13 +578,13 @@ class TimingFunctionPreviewWidget { */ function parseTimingFunction(value) { value = value.trim(); - const tokenStream = getCSSLexer(value); + const tokenStream = new InspectorCSSParserWrapper(value); const getNextToken = () => { while (true) { const token = tokenStream.nextToken(); if ( !token || - (token.tokenType !== "whitespace" && token.tokenType !== "comment") + (token.tokenType !== "WhiteSpace" && token.tokenType !== "Comment") ) { return token; } @@ -592,7 +592,7 @@ function parseTimingFunction(value) { }; let token = getNextToken(); - if (!token || token.tokenType !== "function" || token.text !== "linear") { + if (!token || token.tokenType !== "Function" || token.value !== "linear") { return undefined; } @@ -601,11 +601,11 @@ function parseTimingFunction(value) { let largestInput = -Infinity; while ((token = getNextToken())) { - if (token.text === ")") { + if (token.tokenType === "CloseParenthesis") { break; } - if (token.tokenType === "number") { + if (token.tokenType === "Number") { // [parsing step 4.1] const point = { input: null, output: token.number }; // [parsing step 4.2] @@ -614,7 +614,7 @@ function parseTimingFunction(value) { // get nextToken to see if there's a linear stop length token = getNextToken(); // [parsing step 4.3] - if (token && token.tokenType === "percentage") { + if (token && token.tokenType === "Percentage") { // [parsing step 4.3.1] point.input = Math.max(token.number, largestInput); // [parsing step 4.3.2] @@ -624,7 +624,7 @@ function parseTimingFunction(value) { token = getNextToken(); // [parsing step 4.3.3] - if (token && token.tokenType === "percentage") { + if (token && token.tokenType === "Percentage") { // [parsing step 4.3.3.1] const extraPoint = { input: null, output: point.output }; // [parsing step 4.3.3.2] -- cgit v1.2.3