UNPKG

react-native

Version:

A framework for building native apps using React

820 lines (749 loc) • 26.1 kB
/** * Copyright (c) Meta Platforms, Inc. and affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * * @format * @flow strict-local */ 'use strict'; import type {ProcessedColorValue} from './processColor'; import type { BackgroundImageValue, RadialGradientPosition, RadialGradientShape, RadialGradientSize, } from './StyleSheetTypes'; const processColor = require('./processColor').default; // Linear Gradient const LINEAR_GRADIENT_DIRECTION_REGEX = /^to\s+(?:top|bottom|left|right)(?:\s+(?:top|bottom|left|right))?/i; const LINEAR_GRADIENT_ANGLE_UNIT_REGEX = /^([+-]?\d*\.?\d+)(deg|grad|rad|turn)$/i; const LINEAR_GRADIENT_DEFAULT_DIRECTION: LinearGradientDirection = { type: 'angle', value: 180, }; type LinearGradientDirection = | {type: 'angle', value: number} | {type: 'keyword', value: string}; type LinearGradientBackgroundImage = { type: 'linear-gradient', direction: LinearGradientDirection, colorStops: $ReadOnlyArray<{ color: ColorStopColor, position: ColorStopPosition, }>, }; // Radial Gradient const DEFAULT_RADIAL_SHAPE = 'ellipse'; const DEFAULT_RADIAL_SIZE = 'farthest-corner'; // center const DEFAULT_RADIAL_POSITION: RadialGradientPosition = { top: '50%', left: '50%', }; type RadialGradientBackgroundImage = { type: 'radial-gradient', shape: RadialGradientShape, size: RadialGradientSize, position: RadialGradientPosition, colorStops: $ReadOnlyArray<{ color: ColorStopColor, position: ColorStopPosition, }>, }; // null color indicate that the transition hint syntax is used. e.g. red, 20%, blue type ColorStopColor = ProcessedColorValue | null; // percentage or pixel value type ColorStopPosition = number | string | null; type ParsedBackgroundImageValue = | LinearGradientBackgroundImage | RadialGradientBackgroundImage; export default function processBackgroundImage( backgroundImage: ?($ReadOnlyArray<BackgroundImageValue> | string), ): $ReadOnlyArray<ParsedBackgroundImageValue> { let result: $ReadOnlyArray<ParsedBackgroundImageValue> = []; if (backgroundImage == null) { return result; } if (typeof backgroundImage === 'string') { result = parseBackgroundImageCSSString(backgroundImage.replace(/\n/g, ' ')); } else if (Array.isArray(backgroundImage)) { for (const bgImage of backgroundImage) { 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. return []; } if (bgImage.type === 'linear-gradient') { let direction: LinearGradientDirection = LINEAR_GRADIENT_DEFAULT_DIRECTION; const bgDirection = bgImage.direction != null ? bgImage.direction.toLowerCase() : null; if (bgDirection != null) { if (LINEAR_GRADIENT_ANGLE_UNIT_REGEX.test(bgDirection)) { const parsedAngle = getAngleInDegrees(bgDirection); if (parsedAngle != null) { direction = { type: 'angle', value: parsedAngle, }; } else { // If an angle is invalid, return an empty array and do not apply any gradient. Same as web. return []; } } else if (LINEAR_GRADIENT_DIRECTION_REGEX.test(bgDirection)) { const parsedDirection = getDirectionForKeyword(bgDirection); if (parsedDirection != null) { direction = parsedDirection; } else { // If a direction is invalid, return an empty array and do not apply any gradient. Same as web. return []; } } else { // If a direction is invalid, return an empty array and do not apply any gradient. Same as web. return []; } } result = result.concat({ type: 'linear-gradient', direction, colorStops: processedColorStops, }); } else if (bgImage.type === 'radial-gradient') { let shape: RadialGradientShape = DEFAULT_RADIAL_SHAPE; let size: RadialGradientSize = DEFAULT_RADIAL_SIZE; let position: RadialGradientPosition = {...DEFAULT_RADIAL_POSITION}; if (bgImage.shape != null) { if (bgImage.shape === 'circle' || bgImage.shape === 'ellipse') { shape = bgImage.shape; } else { // If the shape is invalid, return an empty array and do not apply any gradient. Same as web. return []; } } if (bgImage.size != null) { if ( typeof bgImage.size === 'string' && (bgImage.size === 'closest-side' || bgImage.size === 'closest-corner' || bgImage.size === 'farthest-side' || bgImage.size === 'farthest-corner') ) { size = bgImage.size; } else if ( typeof bgImage.size === 'object' && bgImage.size.x != null && bgImage.size.y != null ) { size = { x: bgImage.size.x, y: bgImage.size.y, }; } else { // If the size is invalid, return an empty array and do not apply any gradient. Same as web. return []; } } if (bgImage.position != null) { position = bgImage.position; } result = result.concat({ type: 'radial-gradient', shape, size, position, colorStops: processedColorStops, }); } } } return result; } function processColorStops(bgImage: BackgroundImageValue): $ReadOnlyArray<{ color: ColorStopColor, position: ColorStopPosition, }> | null { const processedColorStops: Array<{ color: ColorStopColor, position: ColorStopPosition, }> = []; for (let index = 0; index < bgImage.colorStops.length; index++) { const colorStop = bgImage.colorStops[index]; const positions = colorStop.positions; // Color transition hint syntax (red, 20%, blue) if ( colorStop.color == null && Array.isArray(positions) && positions.length === 1 ) { const position = positions[0]; if ( typeof position === 'number' || (typeof position === 'string' && position.endsWith('%')) ) { processedColorStops.push({ color: null, position, }); } else { // If a position is invalid, return null and do not apply gradient. Same as web. return null; } } else { const processedColor = processColor(colorStop.color); if (processedColor == null) { // If a color is invalid, return null and do not apply gradient. Same as web. return null; } if (positions != null && positions.length > 0) { for (const position of positions) { if ( typeof position === 'number' || (typeof position === 'string' && position.endsWith('%')) ) { processedColorStops.push({ color: processedColor, position, }); } else { // If a position is invalid, return null and do not apply gradient. Same as web. return null; } } } else { processedColorStops.push({ color: processedColor, position: null, }); } } } return processedColorStops; } function parseBackgroundImageCSSString( cssString: string, ): $ReadOnlyArray<ParsedBackgroundImageValue> { const gradients = []; const bgImageStrings = splitGradients(cssString); for (const bgImageString of bgImageStrings) { const bgImage = bgImageString.toLowerCase(); const gradientRegex = /^(linear|radial)-gradient\(((?:\([^)]*\)|[^()])*)\)/; const match = gradientRegex.exec(bgImage); if (match) { const [, type, gradientContent] = match; const isRadial = type.toLowerCase() === 'radial'; const gradient = isRadial ? parseRadialGradientCSSString(gradientContent) : parseLinearGradientCSSString(gradientContent); if (gradient != null) { gradients.push(gradient); } } } return gradients; } function parseRadialGradientCSSString( gradientContent: string, ): RadialGradientBackgroundImage | null { let shape: RadialGradientShape = DEFAULT_RADIAL_SHAPE; let size: RadialGradientSize = DEFAULT_RADIAL_SIZE; let position: RadialGradientPosition = {...DEFAULT_RADIAL_POSITION}; // split the content by commas, but not if inside parentheses (for color values) const parts = gradientContent.split(/,(?![^(]*\))/); // first part may contain shape, size, and position // [ <radial-shape> || <radial-size> ]? [ at <position> ]? const firstPartStr = parts[0].trim(); const remainingParts = [...parts]; let hasShapeSizeOrPositionString = false; let hasExplicitSingleSize = false; let hasExplicitShape = false; const firstPartTokens = firstPartStr.split(/\s+/); // firstPartTokens is the shape, size, and position while (firstPartTokens.length > 0) { let token = firstPartTokens.shift(); if (token == null) { continue; } let tokenTrimmed = token.toLowerCase().trim(); if (tokenTrimmed === 'circle' || tokenTrimmed === 'ellipse') { shape = tokenTrimmed === 'circle' ? 'circle' : 'ellipse'; hasShapeSizeOrPositionString = true; hasExplicitShape = true; } else if ( tokenTrimmed === 'closest-corner' || tokenTrimmed === 'farthest-corner' || tokenTrimmed === 'closest-side' || tokenTrimmed === 'farthest-side' ) { size = tokenTrimmed; hasShapeSizeOrPositionString = true; } else if (tokenTrimmed.endsWith('px') || tokenTrimmed.endsWith('%')) { let sizeX = getPositionFromCSSValue(tokenTrimmed); if (sizeX == null) { // If a size is invalid, return null and do not apply any gradient. Same as web. return null; } if (typeof sizeX === 'number' && sizeX < 0) { // If a size is invalid, return null and do not apply any gradient. Same as web. return null; } hasShapeSizeOrPositionString = true; size = {x: sizeX, y: sizeX}; token = firstPartTokens.shift(); if (token == null) { hasExplicitSingleSize = true; continue; } tokenTrimmed = token.toLowerCase().trim(); if (tokenTrimmed.endsWith('px') || tokenTrimmed.endsWith('%')) { const sizeY = getPositionFromCSSValue(tokenTrimmed); if (sizeY == null) { // If a size is invalid, return null and do not apply any gradient. Same as web. return null; } if (typeof sizeY === 'number' && sizeY < 0) { // If a size is invalid, return null and do not apply any gradient. Same as web. return null; } size = {x: sizeX, y: sizeY}; } else { hasExplicitSingleSize = true; } } else if (tokenTrimmed === 'at') { let top: string | number; let left: string | number; let right: string | number; let bottom: string | number; hasShapeSizeOrPositionString = true; if (firstPartTokens.length === 0) { // If 'at' is not followed by a position, return null and do not apply any gradient. Same as web. return null; } // 1. [ left | center | right | top | bottom | <length-percentage> ] if (firstPartTokens.length === 1) { token = firstPartTokens.shift(); if (token == null) { // If 'at' is not followed by a position, return null and do not apply any gradient. Same as web. return null; } tokenTrimmed = token.toLowerCase().trim(); if (tokenTrimmed === 'left') { left = '0%'; top = '50%'; } else if (tokenTrimmed === 'center') { left = '50%'; top = '50%'; } else if (tokenTrimmed === 'right') { left = '100%'; top = '50%'; } else if (tokenTrimmed === 'top') { left = '50%'; top = '0%'; } else if (tokenTrimmed === 'bottom') { left = '50%'; top = '100%'; } else if (tokenTrimmed.endsWith('px') || tokenTrimmed.endsWith('%')) { const value = getPositionFromCSSValue(tokenTrimmed); if (value == null) { // If a position is invalid, return null and do not apply any gradient. Same as web. return null; } left = value; top = '50%'; } } if (firstPartTokens.length === 2) { const t1 = firstPartTokens.shift(); const t2 = firstPartTokens.shift(); if (t1 == null || t2 == null) { // If a position is invalid, return null and do not apply any gradient. Same as web. return null; } const token1 = t1.toLowerCase().trim(); const token2 = t2.toLowerCase().trim(); // 2. [ left | center | right ] && [ top | center | bottom ] const horizontalPositions = ['left', 'center', 'right']; const verticalPositions = ['top', 'center', 'bottom']; if ( horizontalPositions.includes(token1) && verticalPositions.includes(token2) ) { left = token1 === 'left' ? '0%' : token1 === 'center' ? '50%' : '100%'; top = token2 === 'top' ? '0%' : token2 === 'center' ? '50%' : '100%'; } else if ( verticalPositions.includes(token1) && horizontalPositions.includes(token2) ) { left = token2 === 'left' ? '0%' : token2 === 'center' ? '50%' : '100%'; top = token1 === 'top' ? '0%' : token1 === 'center' ? '50%' : '100%'; } // 3. [ left | center | right | <length-percentage> ] [ top | center | bottom | <length-percentage> ] else { if (token1 === 'left') { left = '0%'; } else if (token1 === 'center') { left = '50%'; } else if (token1 === 'right') { left = '100%'; } else if (token1.endsWith('px') || token1.endsWith('%')) { const value = getPositionFromCSSValue(token1); if (value == null) { // If a position is invalid, return null and do not apply any gradient. Same as web. return null; } left = value; } else { // If a position is invalid, return null and do not apply any gradient. Same as web. return null; } if (token2 === 'top') { top = '0%'; } else if (token2 === 'center') { top = '50%'; } else if (token2 === 'bottom') { top = '100%'; } else if (token2.endsWith('px') || token2.endsWith('%')) { const value = getPositionFromCSSValue(token2); if (value == null) { // If a position is invalid, return null and do not apply any gradient. Same as web. return null; } top = value; } else { // If a position is invalid, return null and do not apply any gradient. Same as web. return null; } } } // 4. [ [ left | right ] <length-percentage> ] && [ [ top | bottom ] <length-percentage> ] if (firstPartTokens.length === 4) { const t1 = firstPartTokens.shift(); const t2 = firstPartTokens.shift(); const t3 = firstPartTokens.shift(); const t4 = firstPartTokens.shift(); if (t1 == null || t2 == null || t3 == null || t4 == null) { // If a position is invalid, return null and do not apply any gradient. Same as web. return null; } const token1 = t1.toLowerCase().trim(); const token2 = t2.toLowerCase().trim(); const token3 = t3.toLowerCase().trim(); const token4 = t4.toLowerCase().trim(); const keyword1 = token1; const value1 = getPositionFromCSSValue(token2); const keyword2 = token3; const value2 = getPositionFromCSSValue(token4); if (value1 == null || value2 == null) { // If a position is invalid, return null and do not apply any gradient. Same as web. return null; } if (keyword1 === 'left') { left = value1; } else if (keyword1 === 'right') { right = value1; } else if (keyword1 === 'top') { top = value1; } else if (keyword1 === 'bottom') { bottom = value1; } else { // If a position is invalid, return null and do not apply any gradient. Same as web. return null; } if (keyword2 === 'left') { left = value2; } else if (keyword2 === 'right') { right = value2; } else if (keyword2 === 'top') { top = value2; } else if (keyword2 === 'bottom') { bottom = value2; } else { // If a position is invalid, return null and do not apply any gradient. Same as web. return null; } } if (top != null && left != null) { position = { top, left, }; } else if (bottom != null && right != null) { position = { bottom, right, }; } else if (top != null && right != null) { position = { top, right, }; } else if (bottom != null && left != null) { position = { bottom, left, }; } else { // If a position is invalid, return null and do not apply any gradient. Same as web. return null; } // 'at' comes at the end of first part of radial gradient syntax; break; } // if there is no shape, size, or position string found in first token, break // if might be a color stop if (!hasShapeSizeOrPositionString) { break; } } if (hasShapeSizeOrPositionString) { remainingParts.shift(); if (!hasExplicitShape && hasExplicitSingleSize) { shape = 'circle'; } if (hasExplicitSingleSize && hasExplicitShape && shape === 'ellipse') { // If a single size is explicitly set and the shape is an ellipse, return null and do not apply any gradient. Same as web. return null; } } const colorStops = parseColorStopsCSSString(remainingParts); if (colorStops == null) { // If color stops are invalid, return null and do not apply any gradient. Same as web. return null; } return { type: 'radial-gradient', shape, size, position, colorStops, }; } function parseLinearGradientCSSString( gradientContent: string, ): LinearGradientBackgroundImage | null { const parts = gradientContent.split(','); let direction: LinearGradientDirection = LINEAR_GRADIENT_DEFAULT_DIRECTION; const trimmedDirection = parts[0].trim().toLowerCase(); if (LINEAR_GRADIENT_ANGLE_UNIT_REGEX.test(trimmedDirection)) { const parsedAngle = getAngleInDegrees(trimmedDirection); if (parsedAngle != null) { direction = { type: 'angle', value: parsedAngle, }; parts.shift(); } else { // If an angle is invalid, return null and do not apply any gradient. Same as web. return null; } } else if (LINEAR_GRADIENT_DIRECTION_REGEX.test(trimmedDirection)) { const parsedDirection = getDirectionForKeyword(trimmedDirection); if (parsedDirection != null) { direction = parsedDirection; parts.shift(); } else { // If a direction is invalid, return null and do not apply any gradient. Same as web. return null; } } const colorStops = parseColorStopsCSSString(parts); if (colorStops == null) { // If a color stop is invalid, return null and do not apply any gradient. Same as web. return null; } return { type: 'linear-gradient', direction, colorStops, }; } function parseColorStopsCSSString(parts: Array<string>): Array<{ color: ColorStopColor, position: ColorStopPosition, }> | null { const colorStopsString = parts.join(','); const colorStops: Array<{ color: ColorStopColor, position: ColorStopPosition, }> = []; // split by comma, but not if it's inside a parentheses. e.g. red, rgba(0, 0, 0, 0.5), green => ["red", "rgba(0, 0, 0, 0.5)", "green"] const stops = colorStopsString.split(/,(?![^(]*\))/); let prevStop = null; for (let i = 0; i < stops.length; i++) { const stop = stops[i]; const trimmedStop = stop.trim().toLowerCase(); // Match function like pattern or single words const colorStopParts = trimmedStop.match(/\S+\([^)]*\)|\S+/g); if (colorStopParts == null) { // If a color stop is invalid, return null and do not apply any gradient. Same as web. return null; } // Case 1: [color, position, position] if (colorStopParts.length === 3) { const color = colorStopParts[0]; const position1 = getPositionFromCSSValue(colorStopParts[1]); const position2 = getPositionFromCSSValue(colorStopParts[2]); const processedColor = processColor(color); if (processedColor == null) { // If a color is invalid, return null and do not apply any gradient. Same as web. return null; } if (position1 == null || position2 == null) { // If a position is invalid, return null and do not apply any gradient. Same as web. return null; } colorStops.push({ color: processedColor, position: position1, }); colorStops.push({ color: processedColor, position: position2, }); } // Case 2: [color, position] else if (colorStopParts.length === 2) { const color = colorStopParts[0]; const position = getPositionFromCSSValue(colorStopParts[1]); const processedColor = processColor(color); if (processedColor == null) { // If a color is invalid, return null and do not apply any gradient. Same as web. return null; } if (position == null) { // If a position is invalid, return null and do not apply any gradient. Same as web. return null; } colorStops.push({ color: processedColor, position, }); } // Case 3: [color] // Case 4: [position] => transition hint syntax else if (colorStopParts.length === 1) { const position = getPositionFromCSSValue(colorStopParts[0]); if (position != null) { // handle invalid transition hint syntax. transition hint syntax must have color before and after the position. e.g. red, 20%, blue if ( (prevStop != null && prevStop.length === 1 && getPositionFromCSSValue(prevStop[0]) != null) || i === stops.length - 1 || i === 0 ) { // If the last stop is a transition hint syntax, return null and do not apply any gradient. Same as web. return null; } colorStops.push({ color: null, position, }); } else { const processedColor = processColor(colorStopParts[0]); if (processedColor == null) { // If a color is invalid, return null and do not apply any gradient. Same as web. return null; } colorStops.push({ color: processedColor, position: null, }); } } else { // If a color stop is invalid, return null and do not apply any gradient. Same as web. return null; } prevStop = colorStopParts; } return colorStops; } function getDirectionForKeyword(direction?: string): ?LinearGradientDirection { if (direction == null) { return null; } // Remove extra whitespace const normalized = direction.replace(/\s+/g, ' ').toLowerCase(); switch (normalized) { case 'to top': return {type: 'angle', value: 0}; case 'to right': return {type: 'angle', value: 90}; case 'to bottom': return {type: 'angle', value: 180}; case 'to left': return {type: 'angle', value: 270}; case 'to top right': case 'to right top': return {type: 'keyword', value: 'to top right'}; case 'to bottom right': case 'to right bottom': return {type: 'keyword', value: 'to bottom right'}; case 'to top left': case 'to left top': return {type: 'keyword', value: 'to top left'}; case 'to bottom left': case 'to left bottom': return {type: 'keyword', value: 'to bottom left'}; default: return null; } } function getAngleInDegrees(angle?: string): ?number { if (angle == null) { return null; } const match = angle.match(LINEAR_GRADIENT_ANGLE_UNIT_REGEX); if (!match) { return null; } const [, value, unit] = match; const numericValue = parseFloat(value); switch (unit) { case 'deg': return numericValue; case 'grad': return numericValue * 0.9; // 1 grad = 0.9 degrees case 'rad': return (numericValue * 180) / Math.PI; case 'turn': return numericValue * 360; // 1 turn = 360 degrees default: return null; } } function getPositionFromCSSValue(position: string) { if (position.endsWith('px')) { return parseFloat(position); } if (position.endsWith('%')) { return position; } } function splitGradients(input: string) { const result = []; let current = ''; let depth = 0; for (let i = 0; i < input.length; i++) { const char = input[i]; if (char === '(') { depth++; } else if (char === ')') { depth--; } else if (char === ',' && depth === 0) { result.push(current.trim()); current = ''; continue; } current += char; } if (current.trim() !== '') { result.push(current.trim()); } return result; }