react-native
Version:
A framework for building native apps using React
385 lines (351 loc) • 11.5 kB
JavaScript
/**
* 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
*/
;
import type {ProcessedColorValue} from './processColor';
import type {GradientValue} from './StyleSheetTypes';
const processColor = require('./processColor').default;
const DIRECTION_REGEX =
/^to\s+(?:top|bottom|left|right)(?:\s+(?:top|bottom|left|right))?/;
const ANGLE_UNIT_REGEX = /^([+-]?\d*\.?\d+)(deg|grad|rad|turn)$/i;
const TO_BOTTOM_START_END_POINTS = {
start: {x: 0.5, y: 0},
end: {x: 0.5, y: 1},
};
type ParsedGradientValue = {
type: 'linearGradient',
start: {x: number, y: number},
end: {x: number, y: number},
colorStops: $ReadOnlyArray<{
color: ProcessedColorValue,
position: number,
}>,
};
export default function processBackgroundImage(
backgroundImage: ?($ReadOnlyArray<GradientValue> | string),
): $ReadOnlyArray<ParsedGradientValue> {
let result: $ReadOnlyArray<ParsedGradientValue> = [];
if (backgroundImage == null) {
return result;
}
if (typeof backgroundImage === 'string') {
result = parseCSSLinearGradient(backgroundImage.replace(/\n/g, ' '));
} else if (Array.isArray(backgroundImage)) {
for (const bgImage of backgroundImage) {
const processedColorStops: Array<{
color: ProcessedColorValue,
position: number | null,
}> = [];
for (let index = 0; index < bgImage.colorStops.length; index++) {
const colorStop = bgImage.colorStops[index];
const processedColor = processColor(colorStop.color);
if (processedColor == null) {
// If a color is invalid, return an empty array and do not apply gradient. Same as web.
return [];
}
if (colorStop.positions != null && colorStop.positions.length > 0) {
for (const position of colorStop.positions) {
if (position.endsWith('%')) {
processedColorStops.push({
color: processedColor,
position: parseFloat(position) / 100,
});
} else {
// If a position is invalid, return an empty array and do not apply gradient. Same as web.
return [];
}
}
} else {
processedColorStops.push({
color: processedColor,
position: null,
});
}
}
let points: {
start: ParsedGradientValue['start'],
end: ParsedGradientValue['end'],
} | null = null;
if (typeof bgImage.direction === 'undefined') {
points = TO_BOTTOM_START_END_POINTS;
} else if (ANGLE_UNIT_REGEX.test(bgImage.direction)) {
const angle = parseAngle(bgImage.direction);
if (angle != null) {
points = calculateStartEndPointsFromAngle(angle);
}
} else if (DIRECTION_REGEX.test(bgImage.direction)) {
const processedPoints = calculateStartEndPointsFromDirection(
bgImage.direction,
);
if (processedPoints != null) {
points = processedPoints;
}
}
const fixedColorStops = getFixedColorStops(processedColorStops);
if (points != null) {
result = result.concat({
type: 'linearGradient',
start: points.start,
end: points.end,
colorStops: fixedColorStops,
});
}
}
}
return result;
}
function parseCSSLinearGradient(
cssString: string,
): $ReadOnlyArray<ParsedGradientValue> {
const gradients = [];
let match;
const linearGradientRegex = /linear-gradient\s*\(((?:\([^)]*\)|[^())])*)\)/gi;
while ((match = linearGradientRegex.exec(cssString))) {
const gradientContent = match[1];
const parts = gradientContent.split(',');
let points = TO_BOTTOM_START_END_POINTS;
const trimmedDirection = parts[0].trim().toLowerCase();
const colorStopRegex =
/\s*((?:(?:rgba?|hsla?)\s*\([^)]+\))|#[0-9a-fA-F]+|[a-zA-Z]+)(?:\s+(-?[0-9.]+%?)(?:\s+(-?[0-9.]+%?))?)?\s*/gi;
if (ANGLE_UNIT_REGEX.test(trimmedDirection)) {
const angle = parseAngle(trimmedDirection);
if (angle != null) {
points = calculateStartEndPointsFromAngle(angle);
parts.shift();
} else {
// If an angle is invalid, return an empty array and do not apply any gradient. Same as web.
return [];
}
} else if (DIRECTION_REGEX.test(trimmedDirection)) {
const parsedPoints =
calculateStartEndPointsFromDirection(trimmedDirection);
if (parsedPoints != null) {
points = parsedPoints;
parts.shift();
} else {
// If a direction is invalid, return an empty array and do not apply any gradient. Same as web.
return [];
}
} else if (!colorStopRegex.test(trimmedDirection)) {
// If first part is not an angle/direction or a color stop, return an empty array and do not apply any gradient. Same as web.
return [];
}
colorStopRegex.lastIndex = 0;
const colorStops = [];
const fullColorStopsStr = parts.join(',');
let colorStopMatch;
while ((colorStopMatch = colorStopRegex.exec(fullColorStopsStr))) {
const [, color, position1, position2] = colorStopMatch;
const processedColor = processColor(color.trim().toLowerCase());
if (processedColor == null) {
// If a color is invalid, return an empty array and do not apply any gradient. Same as web.
return [];
}
if (typeof position1 !== 'undefined') {
if (position1.endsWith('%')) {
colorStops.push({
color: processedColor,
position: parseFloat(position1) / 100,
});
} else {
// If a position is invalid, return an empty array and do not apply any gradient. Same as web.
return [];
}
} else {
colorStops.push({
color: processedColor,
position: null,
});
}
if (typeof position2 !== 'undefined') {
if (position2.endsWith('%')) {
colorStops.push({
color: processedColor,
position: parseFloat(position2) / 100,
});
} else {
// If a position is invalid, return an empty array and do not apply any gradient. Same as web.
return [];
}
}
}
const fixedColorStops = getFixedColorStops(colorStops);
gradients.push({
type: 'linearGradient',
start: points.start,
end: points.end,
colorStops: fixedColorStops,
});
}
return gradients;
}
function calculateStartEndPointsFromDirection(direction: string): ?{
start: {x: number, y: number},
end: {x: number, y: number},
} {
// Remove extra whitespace
const normalizedDirection = direction.replace(/\s+/g, ' ');
switch (normalizedDirection) {
case 'to right':
return {
start: {x: 0, y: 0.5},
end: {x: 1, y: 0.5},
};
case 'to left':
return {
start: {x: 1, y: 0.5},
end: {x: 0, y: 0.5},
};
case 'to bottom':
return TO_BOTTOM_START_END_POINTS;
case 'to top':
return {
start: {x: 0.5, y: 1},
end: {x: 0.5, y: 0},
};
case 'to bottom right':
case 'to right bottom':
return {
start: {x: 0, y: 0},
end: {x: 1, y: 1},
};
case 'to top left':
case 'to left top':
return {
start: {x: 1, y: 1},
end: {x: 0, y: 0},
};
case 'to bottom left':
case 'to left bottom':
return {
start: {x: 1, y: 0},
end: {x: 0, y: 1},
};
case 'to top right':
case 'to right top':
return {
start: {x: 0, y: 1},
end: {x: 1, y: 0},
};
default:
return null;
}
}
function calculateStartEndPointsFromAngle(angleRadians: number): {
start: {x: number, y: number},
end: {x: number, y: number},
} {
// Normalize angle to be between 0 and 2π
let angleRadiansNormalized = angleRadians % (2 * Math.PI);
if (angleRadiansNormalized < 0) {
angleRadiansNormalized += 2 * Math.PI;
}
const endX = 0.5 + 0.5 * Math.sin(angleRadiansNormalized);
const endY = 0.5 - 0.5 * Math.cos(angleRadiansNormalized);
const startX = 1 - endX;
const startY = 1 - endY;
return {
start: {x: startX, y: startY},
end: {x: endX, y: endY},
};
}
function parseAngle(angle: string): ?number {
const match = angle.match(ANGLE_UNIT_REGEX);
if (!match) {
return null;
}
const [, value, unit] = match;
const numericValue = parseFloat(value);
switch (unit) {
case 'deg':
return (numericValue * Math.PI) / 180;
case 'grad':
return (numericValue * Math.PI) / 200;
case 'rad':
return numericValue;
case 'turn':
return numericValue * 2 * Math.PI;
default:
return null;
}
}
// https://drafts.csswg.org/css-images-4/#color-stop-fixup
function getFixedColorStops(
colorStops: $ReadOnlyArray<{
color: ProcessedColorValue,
position: number | null,
}>,
): Array<{
color: ProcessedColorValue,
position: number,
}> {
let fixedColorStops: Array<{
color: ProcessedColorValue,
position: number,
}> = [];
let hasNullPositions = false;
let maxPositionSoFar = colorStops[0].position ?? 0;
for (let i = 0; i < colorStops.length; i++) {
const colorStop = colorStops[i];
let newPosition = colorStop.position;
if (newPosition === null) {
// Step 1:
// If the first color stop does not have a position,
// set its position to 0%. If the last color stop does not have a position,
// set its position to 100%.
if (i === 0) {
newPosition = 0;
} else if (i === colorStops.length - 1) {
newPosition = 1;
}
}
// Step 2:
// If a color stop or transition hint has a position
// that is less than the specified position of any color stop or transition hint
// before it in the list, set its position to be equal to the
// largest specified position of any color stop or transition hint before it.
if (newPosition !== null) {
newPosition = Math.max(newPosition, maxPositionSoFar);
fixedColorStops[i] = {
color: colorStop.color,
position: newPosition,
};
maxPositionSoFar = newPosition;
} else {
hasNullPositions = true;
}
}
// Step 3:
// If any color stop still does not have a position,
// then, for each run of adjacent color stops without positions,
// set their positions so that they are evenly spaced between the preceding and
// following color stops with positions.
if (hasNullPositions) {
let lastDefinedIndex = 0;
for (let i = 1; i < fixedColorStops.length; i++) {
if (fixedColorStops[i] !== undefined) {
const unpositionedStops = i - lastDefinedIndex - 1;
if (unpositionedStops > 0) {
const startPosition = fixedColorStops[lastDefinedIndex].position;
const endPosition = fixedColorStops[i].position;
const increment =
(endPosition - startPosition) / (unpositionedStops + 1);
for (let j = 1; j <= unpositionedStops; j++) {
fixedColorStops[lastDefinedIndex + j] = {
color: colorStops[lastDefinedIndex + j].color,
position: startPosition + increment * j,
};
}
}
lastDefinedIndex = i;
}
}
}
return fixedColorStops;
}