diff --git a/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.js b/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.js index 0ba483758fb303..d06a7689cff8c2 100644 --- a/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.js +++ b/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.js @@ -777,7 +777,15 @@ type RadialGradientValue = { }>, }; -export type BackgroundImageValue = LinearGradientValue | RadialGradientValue; +type URLBackgroundImageValue = { + type: 'url', + uri: string | number, +}; + +export type BackgroundImageValue = + | LinearGradientValue + | RadialGradientValue + | URLBackgroundImageValue; export type BackgroundSizeValue = | { diff --git a/packages/react-native/Libraries/StyleSheet/__tests__/processBackgroundImage-test.js b/packages/react-native/Libraries/StyleSheet/__tests__/processBackgroundImage-test.js index 91c8207eb8f877..b9ff1dbb210d8e 100644 --- a/packages/react-native/Libraries/StyleSheet/__tests__/processBackgroundImage-test.js +++ b/packages/react-native/Libraries/StyleSheet/__tests__/processBackgroundImage-test.js @@ -361,6 +361,10 @@ describe('processBackgroundImage', () => { {color: 'blue', positions: ['100%']}, ], }, + { + type: 'url', + uri: 'https://example.com', + }, ]; const result = processBackgroundImage(input); expect(result).toEqual([ @@ -372,6 +376,10 @@ describe('processBackgroundImage', () => { {color: processColor('blue'), position: '100%'}, ], }, + { + type: 'url', + uri: 'https://example.com', + }, ]); }); @@ -1153,4 +1161,104 @@ describe('processBackgroundImage', () => { expect(result1).toEqual([]); expect(result2).toEqual([]); }); + + it('should parse url unquoted', () => { + const result = processBackgroundImage('url(https://example.com/image.png)'); + expect(result).toEqual([ + {type: 'url', uri: 'https://example.com/image.png'}, + ]); + }); + + it('should parse url double quoted', () => { + const result = processBackgroundImage( + 'url("https://example.com/image.png")', + ); + expect(result).toEqual([ + {type: 'url', uri: 'https://example.com/image.png'}, + ]); + }); + + it('should parse url single quoted', () => { + const result = processBackgroundImage( + "url('https://example.com/image.png')", + ); + expect(result).toEqual([ + {type: 'url', uri: 'https://example.com/image.png'}, + ]); + }); + + it('should parse url case insensitive', () => { + const result = processBackgroundImage('UrL(https://example.com/image.png)'); + expect(result).toEqual([ + {type: 'url', uri: 'https://example.com/image.png'}, + ]); + }); + + it('should parse url with query params', () => { + const result = processBackgroundImage( + 'url(https://example.com/image.png?size=Large&format=webp)', + ); + expect(result).toEqual([ + { + type: 'url', + uri: 'https://example.com/image.png?size=Large&format=webp', + }, + ]); + }); + + it('should parse url with whitespace', () => { + const result = processBackgroundImage( + 'url( https://example.com/image.png )', + ); + expect(result).toEqual([ + {type: 'url', uri: 'https://example.com/image.png'}, + ]); + }); + + it('should parse multiple urls', () => { + const result = processBackgroundImage( + 'url(https://example.com/bg1.png), url(https://example.com/bg2.png)', + ); + expect(result).toEqual([ + {type: 'url', uri: 'https://example.com/bg1.png'}, + {type: 'url', uri: 'https://example.com/bg2.png'}, + ]); + }); + + it('should parse url mixed with gradients', () => { + const result = processBackgroundImage( + 'radial-gradient(circle at top left, red, blue), url(https://example.com/image.png), linear-gradient(to bottom, green, yellow)', + ); + expect(result).toEqual([ + { + type: 'radial-gradient', + shape: 'circle', + size: 'farthest-corner', + position: {top: '0%', left: '0%'}, + colorStops: [ + {color: processColor('red'), position: null}, + {color: processColor('blue'), position: null}, + ], + }, + {type: 'url', uri: 'https://example.com/image.png'}, + { + type: 'linear-gradient', + direction: {type: 'angle', value: 180}, + colorStops: [ + {color: processColor('green'), position: null}, + {color: processColor('yellow'), position: null}, + ], + }, + ]); + }); + + it('should return empty for url empty', () => { + const result = processBackgroundImage('url()'); + expect(result).toEqual([]); + }); + + it('should return empty for url empty quoted', () => { + const result = processBackgroundImage('url("")'); + expect(result).toEqual([]); + }); }); diff --git a/packages/react-native/Libraries/StyleSheet/processBackgroundImage.js b/packages/react-native/Libraries/StyleSheet/processBackgroundImage.js index ef7906e7e4cb74..a3397019c316a3 100644 --- a/packages/react-native/Libraries/StyleSheet/processBackgroundImage.js +++ b/packages/react-native/Libraries/StyleSheet/processBackgroundImage.js @@ -18,6 +18,7 @@ import type { RadialGradientSize, } from './StyleSheetTypes'; +const resolveAssetSource = require('../Image/resolveAssetSource').default; const processColor = require('./processColor').default; // Linear Gradient @@ -63,6 +64,11 @@ type RadialGradientBackgroundImage = { }>, }; +type URLBackgroundImage = { + type: 'url', + uri: string, +}; + // null color indicate that the transition hint syntax is used. e.g. red, 20%, blue type ColorStopColor = ProcessedColorValue | null; // percentage or pixel value @@ -70,7 +76,8 @@ type ColorStopPosition = number | string | null; type ParsedBackgroundImageValue = | LinearGradientBackgroundImage - | RadialGradientBackgroundImage; + | RadialGradientBackgroundImage + | URLBackgroundImage; export default function processBackgroundImage( backgroundImage: ?($ReadOnlyArray | string), @@ -84,6 +91,28 @@ export default function processBackgroundImage( result = parseBackgroundImageCSSString(backgroundImage.replace(/\n/g, ' ')); } else if (Array.isArray(backgroundImage)) { for (const bgImage of backgroundImage) { + if (bgImage.type === 'url') { + let uri: ?string = null; + if (typeof bgImage.uri === 'string') { + uri = bgImage.uri; + } else if (typeof bgImage.uri === 'number') { + const source = resolveAssetSource(bgImage.uri); + if (source != null && source.uri != null) { + uri = source.uri; + } + } + if (uri != null) { + result = result.concat({ + type: 'url', + uri, + }); + continue; + } else { + // If the URI is invalid, return an empty array. Same as web. + return []; + } + } + const processedColorStops = processColorStops(bgImage); if (processedColorStops == null) { // If a color stop is invalid, return an empty array and do not apply any gradient. Same as web. @@ -250,13 +279,29 @@ function processColorStops(bgImage: BackgroundImageValue): $ReadOnlyArray<{ function parseBackgroundImageCSSString( cssString: string, ): $ReadOnlyArray { - const gradients = []; + const backgroundImages = []; const bgImageStrings = splitGradients(cssString); for (const bgImageString of bgImageStrings) { + const urlRegex = /^url\((.*)\)$/i; + const urlMatch = urlRegex.exec(bgImageString); + if (urlMatch) { + let uri = urlMatch[1].trim(); + const first = uri[0]; + if ((first === '"' || first === "'") && uri.endsWith(first)) { + uri = uri.slice(1, -1); + } + if (uri.length > 0) { + backgroundImages.push({ + type: 'url', + uri, + }); + } + continue; + } + const bgImage = bgImageString.toLowerCase(); const gradientRegex = /^(linear|radial)-gradient\(((?:\([^)]*\)|[^()])*)\)/; - const match = gradientRegex.exec(bgImage); if (match) { const [, type, gradientContent] = match; @@ -266,11 +311,11 @@ function parseBackgroundImageCSSString( : parseLinearGradientCSSString(gradientContent); if (gradient != null) { - gradients.push(gradient); + backgroundImages.push(gradient); } } } - return gradients; + return backgroundImages; } function parseRadialGradientCSSString( diff --git a/packages/react-native/ReactCommon/react/renderer/components/view/BackgroundImagePropsConversions.cpp b/packages/react-native/ReactCommon/react/renderer/components/view/BackgroundImagePropsConversions.cpp index 6861ab759ba6aa..cd00d4d2ed1ec2 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/view/BackgroundImagePropsConversions.cpp +++ b/packages/react-native/ReactCommon/react/renderer/components/view/BackgroundImagePropsConversions.cpp @@ -210,6 +210,14 @@ void parseProcessedBackgroundImage( } backgroundImage.emplace_back(std::move(radialGradient)); + } else if (type == "url") { + auto uriIt = rawBackgroundImageMap.find("uri"); + if (uriIt != rawBackgroundImageMap.end() && + uriIt->second.hasType()) { + URLBackgroundImage urlBackgroundImage; + urlBackgroundImage.uri = (std::string)(uriIt->second); + backgroundImage.emplace_back(std::move(urlBackgroundImage)); + } } } @@ -415,6 +423,14 @@ void parseUnprocessedBackgroundImageList( } backgroundImage.emplace_back(std::move(radialGradient)); + } else if (type == "url") { + auto uriIt = rawBackgroundImageMap.find("uri"); + if (uriIt != rawBackgroundImageMap.end() && + uriIt->second.hasType()) { + URLBackgroundImage urlBackgroundImage; + urlBackgroundImage.uri = (std::string)(uriIt->second); + backgroundImage.emplace_back(std::move(urlBackgroundImage)); + } } } @@ -478,6 +494,13 @@ void fromCSSColorStop( std::optional fromCSSBackgroundImage( const CSSBackgroundImageVariant& cssBackgroundImage) { + if (std::holds_alternative(cssBackgroundImage)) { + const auto& urlFunc = std::get(cssBackgroundImage); + URLBackgroundImage urlBackgroundImage; + urlBackgroundImage.uri = urlFunc.url; + return BackgroundImage{urlBackgroundImage}; + } + if (std::holds_alternative(cssBackgroundImage)) { const auto& gradient = std::get(cssBackgroundImage); diff --git a/packages/react-native/ReactCommon/react/renderer/css/CSSBackgroundImage.h b/packages/react-native/ReactCommon/react/renderer/css/CSSBackgroundImage.h index 5f11cfaf83f067..79bd59df09a5f3 100644 --- a/packages/react-native/ReactCommon/react/renderer/css/CSSBackgroundImage.h +++ b/packages/react-native/ReactCommon/react/renderer/css/CSSBackgroundImage.h @@ -845,11 +845,55 @@ struct CSSDataTypeParser { static_assert(CSSDataType); +struct CSSURLFunction { + std::string url{}; + + bool operator==(const CSSURLFunction &rhs) const = default; +}; + +template <> +struct CSSDataTypeParser { + static auto consumeFunctionBlock(const CSSFunctionBlock &func, CSSSyntaxParser &parser) + -> std::optional + { + if (!iequals(func.name, "url")) { + return {}; + } + + parser.consumeWhitespace(); + std::string url; + while (auto token = parser.consumeComponentValue>( + [](const CSSPreservedToken &t) -> std::optional { + return std::string(t.stringValue()); + })) { + if (!token->empty()) { + url += *token; + } + } + + if (url.size() >= 2) { + char first = url.front(); + char last = url.back(); + if ((first == '"' && last == '"') || (first == '\'' && last == '\'')) { + url = url.substr(1, url.size() - 2); + } + } + + if (!url.empty()) { + return CSSURLFunction{url}; + } + + return {}; + } +}; + +static_assert(CSSDataType); + /** * Representation of * https://www.w3.org/TR/css-backgrounds-3/#background-image */ -using CSSBackgroundImage = CSSCompoundDataType; +using CSSBackgroundImage = CSSCompoundDataType; /** * Variant of possible CSS background image types diff --git a/packages/react-native/ReactCommon/react/renderer/css/tests/CSSBackgroundImageTest.cpp b/packages/react-native/ReactCommon/react/renderer/css/tests/CSSBackgroundImageTest.cpp index 9851cc87277f5e..f5e28b738db830 100644 --- a/packages/react-native/ReactCommon/react/renderer/css/tests/CSSBackgroundImageTest.cpp +++ b/packages/react-native/ReactCommon/react/renderer/css/tests/CSSBackgroundImageTest.cpp @@ -557,4 +557,86 @@ TEST_F(CSSBackgroundImageTest, RadialGradientNegativeRadius) { } } +TEST_F(CSSBackgroundImageTest, URLBasicUnquoted) { + auto result = + parseCSSProperty("url(https://example.com/image.png)"); + decltype(result) expected = CSSURLFunction{.url = "https://example.com/image.png"}; + ASSERT_EQ(result, expected); +} + +TEST_F(CSSBackgroundImageTest, URLDoubleQuoted) { + auto result = + parseCSSProperty("url(\"https://example.com/image.png\")"); + decltype(result) expected = CSSURLFunction{.url = "https://example.com/image.png"}; + ASSERT_EQ(result, expected); +} + +TEST_F(CSSBackgroundImageTest, URLSingleQuoted) { + auto result = + parseCSSProperty("url('https://example.com/image.png')"); + decltype(result) expected = CSSURLFunction{.url = "https://example.com/image.png"}; + ASSERT_EQ(result, expected); +} + +TEST_F(CSSBackgroundImageTest, URLCaseInsensitive) { + auto result = + parseCSSProperty("UrL(https://example.com/image.png)"); + decltype(result) expected = CSSURLFunction{.url = "https://example.com/image.png"}; + ASSERT_EQ(result, expected); +} + +TEST_F(CSSBackgroundImageTest, URLWithQueryParams) { + auto result = + parseCSSProperty("url(https://example.com/image.png?size=Large&format=webp)"); + decltype(result) expected = CSSURLFunction{.url = "https://example.com/image.png?size=Large&format=webp"}; + ASSERT_EQ(result, expected); +} + +TEST_F(CSSBackgroundImageTest, URLWithWhitespace) { + auto result = + parseCSSProperty("url( https://example.com/image.png )"); + decltype(result) expected = CSSURLFunction{.url = "https://example.com/image.png"}; + ASSERT_EQ(result, expected); +} + +TEST_F(CSSBackgroundImageTest, MultipleURLs) { + auto result = parseCSSProperty( + "url(https://example.com/bg1.png), url(https://example.com/bg2.png)"); + decltype(result) expected = CSSBackgroundImageList{ + {CSSURLFunction{.url = "https://example.com/bg1.png"}, + CSSURLFunction{.url = "https://example.com/bg2.png"}}}; + ASSERT_EQ(result, expected); +} + +TEST_F(CSSBackgroundImageTest, URLMixedWithGradients) { + auto result = parseCSSProperty( + "radial-gradient(circle at top left, red, blue), url(https://example.com/image.png), linear-gradient(to bottom, green, yellow)"); + decltype(result) expected = CSSBackgroundImageList{ + {CSSRadialGradientFunction{ + .shape = CSSRadialGradientShape::Circle, + .size = CSSRadialGradientSizeKeyword::FarthestCorner, + .position = + CSSRadialGradientPosition{ + .top = CSSPercentage{.value = 0.0f}, + .left = CSSPercentage{.value = 0.0f}}, + .items = {makeCSSColorStop(255, 0, 0), makeCSSColorStop(0, 0, 255)}}, + CSSURLFunction{.url = "https://example.com/image.png"}, + CSSLinearGradientFunction{ + .direction = + CSSLinearGradientDirection{.value = CSSAngle{.degrees = 180.0f}}, + .items = { + makeCSSColorStop(0, 128, 0), makeCSSColorStop(255, 255, 0)}}}}; + ASSERT_EQ(result, expected); +} + +TEST_F(CSSBackgroundImageTest, URLEmpty) { + auto result = parseCSSProperty("url()"); + ASSERT_TRUE(std::holds_alternative(result)); +} + +TEST_F(CSSBackgroundImageTest, URLEmptyQuoted) { + auto result = parseCSSProperty("url(\"\")"); + ASSERT_TRUE(std::holds_alternative(result)); +} + } // namespace facebook::react diff --git a/packages/react-native/ReactCommon/react/renderer/graphics/BackgroundImage.h b/packages/react-native/ReactCommon/react/renderer/graphics/BackgroundImage.h index d156126f251c6c..487e58c7ea0120 100644 --- a/packages/react-native/ReactCommon/react/renderer/graphics/BackgroundImage.h +++ b/packages/react-native/ReactCommon/react/renderer/graphics/BackgroundImage.h @@ -7,12 +7,34 @@ #pragma once +#include + #include #include namespace facebook::react { -using BackgroundImage = std::variant; +class ImageSource; + +struct URLBackgroundImage { + std::string uri{}; + + bool operator==(const URLBackgroundImage& rhs) const { + return uri == rhs.uri; + } + + bool operator!=(const URLBackgroundImage& rhs) const { + return !(*this == rhs); + } + +#if RN_DEBUG_STRING_CONVERTIBLE + void toString(std::stringstream& ss) const { + ss << "url(" << uri << ")"; + } +#endif +}; + +using BackgroundImage = std::variant; #ifdef RN_SERIALIZABLE_STATE folly::dynamic toDynamic(const BackgroundImage &backgroundImage); @@ -34,6 +56,8 @@ inline std::string toString(std::vector &value) std::get(backgroundImage).toString(ss); } else if (std::holds_alternative(backgroundImage)) { std::get(backgroundImage).toString(ss); + } else if (std::holds_alternative(backgroundImage)) { + std::get(backgroundImage).toString(ss); } } ss << "]"; diff --git a/packages/rn-tester/js/examples/BackgroundImage/BackgroundImageExample.js b/packages/rn-tester/js/examples/BackgroundImage/BackgroundImageExample.js index faf8c05b31e1b0..50d984afc43f35 100644 --- a/packages/rn-tester/js/examples/BackgroundImage/BackgroundImageExample.js +++ b/packages/rn-tester/js/examples/BackgroundImage/BackgroundImageExample.js @@ -459,4 +459,44 @@ exports.examples = [ ); }, }, + { + title: 'URL Image', + name: 'url-image', + render(): React.Node { + return ( + + ); + }, + }, + { + title: 'URL Image from local file', + name: 'local-image', + render(): React.Node { + return ( + + ); + }, + }, ] as Array;