'use strict'; import type { ViewStyle } from 'react-native'; import type { ValueProcessor, ValueProcessorContext } from '../../types'; import { CSS_NUMBER_PATTERN, getAngleInDegrees, isPercentage, splitByComma, splitByWhitespace, } from '../../utils'; import type { ProcessedColor } from './colors'; import { processColor, processColorNumber } from './colors'; type BackgroundImageStyleValue = NonNullable< ViewStyle[Extract< keyof ViewStyle, 'backgroundImage' | 'experimental_backgroundImage' >] >; type BackgroundImageValue = Exclude[number]; type RadialGradientValue = Extract< BackgroundImageValue, { type: 'radial-gradient' } >; type RadialGradientShape = RadialGradientValue['shape']; type RadialGradientSize = RadialGradientValue['size']; type RadialGradientPosition = RadialGradientValue['position']; const NEWLINE_REGEX = /\n/g; const WHITESPACE_NORMALIZE_REGEX = /\s+/g; const GRADIENT_REGEX = /^(linear|radial)-gradient\(((?:\([^)]*\)|[^()])*)\)/; const COLOR_STOP_PARTS_REGEX = /\S+\([^)]*\)|\S+/g; const PX_LENGTH_REGEX = new RegExp(`^${CSS_NUMBER_PATTERN}px$`); const LINEAR_GRADIENT_DIRECTION_REGEX = /^to\s+(?:top|bottom|left|right)(?:\s+(?:top|bottom|left|right))?/; const DEFAULT_DIRECTION = { type: 'angle', value: 180 } as const; const DEFAULT_RADIAL_SHAPE = 'ellipse'; const DEFAULT_RADIAL_SIZE = 'farthest-corner'; const DEFAULT_RADIAL_POSITION = { top: '50%', left: '50%' }; const MIN_COLOR_STOPS = 2; const RADIAL_SIZE_KEYWORDS = [ 'closest-side', 'closest-corner', 'farthest-side', 'farthest-corner', ]; export const ERROR_MESSAGES = { invalidDirection(direction: string) { 'worklet'; return `Invalid direction "${direction}" in background image.`; }, invalidPosition(position: unknown) { 'worklet'; return `Invalid position "${String(position)}" in background image color stop.`; }, invalidShape(shape: string) { 'worklet'; return `Invalid shape "${shape}" in radial gradient.`; }, invalidSize(size: unknown) { 'worklet'; return `Invalid size ${JSON.stringify(size)} in radial gradient.`; }, invalidTransitionHint(position: unknown) { 'worklet'; return `Invalid transition hint "${String(position)}" in background image: a hint must be placed between two color stops.`; }, notEnoughColorStops(count: number) { 'worklet'; return `Background image gradient must have at least two color stops, got ${count}.`; }, }; export type ProcessedDirection = | { type: 'angle'; value: number } | { type: 'keyword'; value: string }; export type ProcessedColorStop = { color: ProcessedColor | null; position: number | string | null; }; export type ProcessedBackgroundImageValue = | { type: 'linear-gradient'; direction: ProcessedDirection; colorStops: ProcessedColorStop[]; } | { type: 'radial-gradient'; shape: RadialGradientShape; size: RadialGradientSize; position: RadialGradientPosition; colorStops: ProcessedColorStop[]; }; const getDirectionForKeyword = ( direction: string ): ProcessedDirection | null => { 'worklet'; switch (direction.replace(WHITESPACE_NORMALIZE_REGEX, ' ')) { 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; } }; const parseDirection = (direction: string): ProcessedDirection | null => { 'worklet'; const normalized = direction.toLowerCase(); const angle = getAngleInDegrees(normalized); if (angle !== null) { return { type: 'angle', value: angle }; } return getDirectionForKeyword(normalized); }; const processDirection = (direction?: string): ProcessedDirection => { 'worklet'; if (direction == null) { return DEFAULT_DIRECTION; } const parsed = parseDirection(direction); if (parsed === null) { throw new Error( `[Reanimated] ${ERROR_MESSAGES.invalidDirection(direction)}` ); } return parsed; }; const isValidPosition = (position: unknown): position is number | string => { 'worklet'; return typeof position === 'number' || isPercentage(position); }; const processColorStops = ( colorStops: BackgroundImageValue['colorStops'], context?: ValueProcessorContext ): ProcessedColorStop[] => { 'worklet'; const result: ProcessedColorStop[] = []; for (const colorStop of colorStops) { const { color, positions } = colorStop; if (color == null && positions?.length === 1) { if (!isValidPosition(positions[0])) { throw new Error( `[Reanimated] ${ERROR_MESSAGES.invalidPosition(positions[0])}` ); } result.push({ color: null, position: positions[0] }); continue; } const processedColor = processColor(color, context); if (!positions?.length) { result.push({ color: processedColor, position: null }); continue; } for (const position of positions) { if (!isValidPosition(position)) { throw new Error( `[Reanimated] ${ERROR_MESSAGES.invalidPosition(position)}` ); } result.push({ color: processedColor, position }); } } for (let i = 0; i < result.length; i++) { const { color, position } = result[i]; if ( color === null && (i === 0 || i === result.length - 1 || result[i - 1].color === null) ) { throw new Error( `[Reanimated] ${ERROR_MESSAGES.invalidTransitionHint(position)}` ); } } if (result.length < MIN_COLOR_STOPS) { throw new Error( `[Reanimated] ${ERROR_MESSAGES.notEnoughColorStops(result.length)}` ); } return result; }; const processRadialSize = (size?: RadialGradientSize): RadialGradientSize => { 'worklet'; if (size == null) { return DEFAULT_RADIAL_SIZE; } if (typeof size === 'string' && RADIAL_SIZE_KEYWORDS.includes(size)) { return size; } if (typeof size === 'object' && size.x != null && size.y != null) { return { x: size.x, y: size.y }; } throw new Error(`[Reanimated] ${ERROR_MESSAGES.invalidSize(size)}`); }; const processRadialShape = ( shape?: RadialGradientShape ): RadialGradientShape => { 'worklet'; if (shape == null) { return DEFAULT_RADIAL_SHAPE; } if (shape !== 'circle' && shape !== 'ellipse') { throw new Error(`[Reanimated] ${ERROR_MESSAGES.invalidShape(shape)}`); } return shape; }; const getPositionFromCSSValue = (value: string): number | string | null => { 'worklet'; if (isPercentage(value)) { return value; } if (PX_LENGTH_REGEX.test(value)) { return parseFloat(value); } return null; }; const parseCSSColor = ( color: string, context?: ValueProcessorContext ): ProcessedColor | null => { 'worklet'; if (processColorNumber(color) === null) { return null; } return processColor(color, context); }; const isLengthOrPercentageToken = (token: string): boolean => { 'worklet'; return token.endsWith('px') || token.endsWith('%'); }; const parseColorStopsCSSString = ( parts: string[], context?: ValueProcessorContext ): ProcessedColorStop[] | null => { 'worklet'; const stops = splitByComma(parts.join(',')); const result: ProcessedColorStop[] = []; let previousWasHint = false; for (let i = 0; i < stops.length; i++) { const colorStopParts = stops[i].trim().match(COLOR_STOP_PARTS_REGEX); if (colorStopParts === null) { return null; } if (colorStopParts.length === 3) { const color = parseCSSColor(colorStopParts[0], context); const position1 = getPositionFromCSSValue(colorStopParts[1]); const position2 = getPositionFromCSSValue(colorStopParts[2]); if (color === null || position1 === null || position2 === null) { return null; } result.push({ color, position: position1 }); result.push({ color, position: position2 }); previousWasHint = false; } else if (colorStopParts.length === 2) { const color = parseCSSColor(colorStopParts[0], context); const position = getPositionFromCSSValue(colorStopParts[1]); if (color === null || position === null) { return null; } result.push({ color, position }); previousWasHint = false; } else if (colorStopParts.length === 1) { const position = getPositionFromCSSValue(colorStopParts[0]); if (position !== null) { if (previousWasHint || i === 0 || i === stops.length - 1) { return null; } result.push({ color: null, position }); previousWasHint = true; } else { const color = parseCSSColor(colorStopParts[0], context); if (color === null) { return null; } result.push({ color, position: null }); previousWasHint = false; } } else { return null; } } if (result.length < MIN_COLOR_STOPS) { return null; } return result; }; const parseLinearGradientCSSString = ( content: string, context?: ValueProcessorContext ): ProcessedBackgroundImageValue | null => { 'worklet'; const parts = content.split(','); const firstPart = parts[0].trim(); let direction: ProcessedDirection = DEFAULT_DIRECTION; if ( getAngleInDegrees(firstPart) !== null || LINEAR_GRADIENT_DIRECTION_REGEX.test(firstPart) ) { const parsed = parseDirection(firstPart); if (parsed === null) { return null; } direction = parsed; parts.shift(); } const colorStops = parseColorStopsCSSString(parts, context); if (colorStops === null) { return null; } return { type: 'linear-gradient', direction, colorStops }; }; const HORIZONTAL_POSITION_KEYWORDS: Record = { left: '0%', center: '50%', right: '100%', }; const VERTICAL_POSITION_KEYWORDS: Record = { top: '0%', center: '50%', bottom: '100%', }; const getKeywordPosition = ( keywords: Record, token: string ): string | null => { 'worklet'; return Object.prototype.hasOwnProperty.call(keywords, token) ? keywords[token] : null; }; const parseRadialPosition = ( tokens: string[] ): RadialGradientPosition | null => { 'worklet'; let top: string | number | undefined; let left: string | number | undefined; let right: string | number | undefined; let bottom: string | number | undefined; if (tokens.length === 1) { const token = tokens[0]; const horizontal = getKeywordPosition(HORIZONTAL_POSITION_KEYWORDS, token); const vertical = getKeywordPosition(VERTICAL_POSITION_KEYWORDS, token); if (horizontal !== null) { left = horizontal; top = '50%'; } else if (vertical !== null) { left = '50%'; top = vertical; } else if (isLengthOrPercentageToken(token)) { const value = getPositionFromCSSValue(token); if (value === null) { return null; } left = value; top = '50%'; } } else if (tokens.length === 2) { const [token1, token2] = tokens; const horizontal1 = getKeywordPosition( HORIZONTAL_POSITION_KEYWORDS, token1 ); const vertical1 = getKeywordPosition(VERTICAL_POSITION_KEYWORDS, token1); const horizontal2 = getKeywordPosition( HORIZONTAL_POSITION_KEYWORDS, token2 ); const vertical2 = getKeywordPosition(VERTICAL_POSITION_KEYWORDS, token2); if (horizontal1 !== null && vertical2 !== null) { left = horizontal1; top = vertical2; } else if (vertical1 !== null && horizontal2 !== null) { left = horizontal2; top = vertical1; } else { if (horizontal1 !== null) { left = horizontal1; } else if (isLengthOrPercentageToken(token1)) { const value = getPositionFromCSSValue(token1); if (value === null) { return null; } left = value; } else { return null; } if (vertical2 !== null) { top = vertical2; } else if (isLengthOrPercentageToken(token2)) { const value = getPositionFromCSSValue(token2); if (value === null) { return null; } top = value; } else { return null; } } } else if (tokens.length === 4) { for (const [keyword, rawValue] of [ [tokens[0], tokens[1]], [tokens[2], tokens[3]], ]) { const value = getPositionFromCSSValue(rawValue); if (value === null) { return null; } if (keyword === 'left') { left = value; } else if (keyword === 'right') { right = value; } else if (keyword === 'top') { top = value; } else if (keyword === 'bottom') { bottom = value; } else { return null; } } } if (top != null && left != null) { return { top, left }; } if (bottom != null && right != null) { return { bottom, right }; } if (top != null && right != null) { return { top, right }; } if (bottom != null && left != null) { return { bottom, left }; } return null; }; const parseRadialGradientCSSString = ( content: string, context?: ValueProcessorContext ): ProcessedBackgroundImageValue | null => { 'worklet'; let shape: RadialGradientShape = DEFAULT_RADIAL_SHAPE; let size: RadialGradientSize = DEFAULT_RADIAL_SIZE; let position: RadialGradientPosition = { ...DEFAULT_RADIAL_POSITION }; const parts = splitByComma(content); const tokens = splitByWhitespace(parts[0]); let hasShapeSizeOrPosition = false; let hasExplicitSingleSize = false; let hasExplicitShape = false; while (tokens.length > 0) { const token = tokens.shift()!; if (token === 'circle' || token === 'ellipse') { shape = token; hasShapeSizeOrPosition = true; hasExplicitShape = true; } else if (RADIAL_SIZE_KEYWORDS.includes(token)) { size = token as RadialGradientSize; hasShapeSizeOrPosition = true; } else if (isLengthOrPercentageToken(token)) { const sizeX = getPositionFromCSSValue(token); if (sizeX === null || parseFloat(token) < 0) { return null; } hasShapeSizeOrPosition = true; size = { x: sizeX, y: sizeX }; const nextToken = tokens[0]; if (nextToken !== undefined && isLengthOrPercentageToken(nextToken)) { tokens.shift(); const sizeY = getPositionFromCSSValue(nextToken); if (sizeY === null || parseFloat(nextToken) < 0) { return null; } size = { x: sizeX, y: sizeY }; } else { hasExplicitSingleSize = true; } } else if (token === 'at') { hasShapeSizeOrPosition = true; const parsedPosition = parseRadialPosition(tokens.splice(0)); if (parsedPosition === null) { return null; } position = parsedPosition; } if (!hasShapeSizeOrPosition) { break; } } if (hasShapeSizeOrPosition) { parts.shift(); if (!hasExplicitShape && hasExplicitSingleSize) { shape = 'circle'; } if (hasExplicitSingleSize && hasExplicitShape && shape === 'ellipse') { return null; } } const colorStops = parseColorStopsCSSString(parts, context); if (colorStops === null) { return null; } return { type: 'radial-gradient', shape, size, position, colorStops }; }; const parseBackgroundImageCSSString = ( value: string, context?: ValueProcessorContext ): ProcessedBackgroundImageValue[] => { 'worklet'; const result: ProcessedBackgroundImageValue[] = []; for (const gradientString of splitByComma( value.replace(NEWLINE_REGEX, ' ') )) { const match = GRADIENT_REGEX.exec(gradientString.toLowerCase()); if (!match) { continue; } const [, type, content] = match; const gradient = type === 'radial' ? parseRadialGradientCSSString(content, context) : parseLinearGradientCSSString(content, context); if (gradient !== null) { result.push(gradient); } } return result; }; export const processBackgroundImage: ValueProcessor< BackgroundImageStyleValue, ProcessedBackgroundImageValue[] | undefined > = (value, context) => { 'worklet'; if (typeof value === 'string') { return parseBackgroundImageCSSString(value, context); } if (!Array.isArray(value)) { return; } const result: ProcessedBackgroundImageValue[] = []; for (const backgroundImage of value) { if (backgroundImage.type === 'linear-gradient') { result.push({ type: 'linear-gradient', direction: processDirection(backgroundImage.direction), colorStops: processColorStops(backgroundImage.colorStops, context), }); } else if (backgroundImage.type === 'radial-gradient') { result.push({ type: 'radial-gradient', shape: processRadialShape(backgroundImage.shape), size: processRadialSize(backgroundImage.size), position: backgroundImage.position ?? { ...DEFAULT_RADIAL_POSITION }, colorStops: processColorStops(backgroundImage.colorStops, context), }); } } return result; };