@bacons/css-to-expo-linear-gradient
Version:
Convert a CSS linear gradient function to expo-linear-gradient props
288 lines (255 loc) • 8.89 kB
text/typescript
// From https://github.com/niklasvh/html2canvas/tree/eeda86bd5e81fb4e97675fe9bee3d4d15899997f
type Bounds = { width: number; height: number };
type Direction = {
x0: number;
x1: number;
y0: number;
y1: number;
};
const ANGLE = /([+-]?\d*\.?\d+)(deg|grad|rad|turn)/i;
const SIDE_OR_CORNER =
/^(to )?(left|top|right|bottom)( (left|top|right|bottom))?$/i;
const PERCENTAGE_ANGLES = /^([+-]?\d*\.?\d+)% ([+-]?\d*\.?\d+)%$/i;
const ENDS_WITH_LENGTH = /(px)$|(%$)|( 0$)/;
const FROM_TO_COLORSTOP =
/^(from|to|color-stop)\((?:([\d.]+)(%)?,\s*)?(.+?)\)$/i;
const parseAngle = (angle: string): number | null => {
const match = angle.match(ANGLE);
if (match) {
const value = parseFloat(match[1]);
switch (match[2].toLowerCase()) {
case "deg":
return (Math.PI * value) / 180;
case "grad":
return (Math.PI / 200) * value;
case "rad":
return value;
case "turn":
return Math.PI * 2 * value;
}
}
return null;
};
const distance = (a: number, b: number): number =>
Math.sqrt(a * a + b * b);
const parseGradient = (
{
args,
method,
prefix,
}: { args: Array<string>; method: string; prefix?: string },
bounds: Bounds
) => {
if (method === "linear-gradient") {
return parseLinearGradient(args, bounds, !!prefix);
} else if (method === "gradient" && args[0] === "linear") {
// TODO handle correct angle
return parseLinearGradient(
["to bottom"].concat(transformObsoleteColorStops(args.slice(3))),
bounds,
!!prefix
);
} else if (
method === "radial-gradient" ||
(method === "gradient" && args[0] === "radial")
) {
throw new Error("radial gradients are not supported");
}
throw new Error("unknown gradient type: " + method);
};
const parseColorStops = (args: Array<string>, firstColorStopIndex: number) => {
const colorStops: Array<{ color: string; stop: string | null }> = [];
for (let i = firstColorStopIndex; i < args.length; i++) {
const value = args[i];
const HAS_LENGTH = ENDS_WITH_LENGTH.test(value);
const lastSpaceIndex = value.lastIndexOf(" ");
const color = HAS_LENGTH ? value.substring(0, lastSpaceIndex) : value;
const stop = HAS_LENGTH
? value.substring(lastSpaceIndex + 1)
: i === firstColorStopIndex
? "0%"
: i === args.length - 1
? "100%"
: // fallback to evenly spaced color stops
// prettier-ignore
`${((i - firstColorStopIndex) / (args.length - firstColorStopIndex - 1)) * 100}%`;
colorStops.push({ color, stop });
}
const parsePosition = (p) => {
const percentage = parseInt(p.replace(/%$/, ""), 10);
const normal = percentage / 100;
return normal;
};
const colors: string[] = [];
const locations: number[] = [];
colorStops.forEach(({ color, stop }) => {
colors.push(color);
locations.push(parsePosition(stop));
});
colors.reverse();
return { colors, locations };
};
const parseLinearGradient = (
args: Array<string>,
bounds: Bounds,
hasPrefix: boolean
) => {
const angle = parseAngle(args[0]);
const HAS_SIDE_OR_CORNER = SIDE_OR_CORNER.test(args[0]);
const HAS_DIRECTION =
HAS_SIDE_OR_CORNER || angle !== null || PERCENTAGE_ANGLES.test(args[0]);
const direction = HAS_DIRECTION
? angle !== null
? calculateGradientDirection(
// if there is a prefix, the 0° angle points due East (instead of North per W3C)
hasPrefix ? angle - Math.PI * 0.5 : angle,
bounds
)
: HAS_SIDE_OR_CORNER
? parseSideOrCorner(args[0], bounds)
: parsePercentageAngle(args[0], bounds)
: calculateGradientDirection(Math.PI, bounds);
const firstColorStopIndex = HAS_DIRECTION ? 1 : 0;
return {
...parseColorStops(args, firstColorStopIndex),
start: { x: direction.x0, y: direction.y0 },
end: { x: direction.x1, y: direction.y1 },
};
};
const calculateGradientDirection = (
radian: number,
bounds: Bounds
): Direction => {
const width = bounds.width;
const height = bounds.height;
const HALF_WIDTH = width * 0.5;
const HALF_HEIGHT = height * 0.5;
const lineLength =
Math.abs(width * Math.sin(radian)) + Math.abs(height * Math.cos(radian));
const HALF_LINE_LENGTH = lineLength / 2;
const x0 = HALF_WIDTH + Math.sin(radian) * HALF_LINE_LENGTH;
const y0 = HALF_HEIGHT - Math.cos(radian) * HALF_LINE_LENGTH;
const x1 = width - x0;
const y1 = height - y0;
return { x0, x1, y0, y1 };
};
const parseTopRight = (bounds: Bounds) =>
Math.acos(bounds.width / 2 / (distance(bounds.width, bounds.height) / 2));
const parseSideOrCorner = (side: string, bounds: Bounds): Direction => {
switch (side) {
case "bottom":
case "to top":
return calculateGradientDirection(0, bounds);
case "left":
case "to right":
return calculateGradientDirection(Math.PI / 2, bounds);
case "right":
case "to left":
return calculateGradientDirection((3 * Math.PI) / 2, bounds);
case "top right":
case "right top":
case "to bottom left":
case "to left bottom":
return calculateGradientDirection(
Math.PI + parseTopRight(bounds),
bounds
);
case "top left":
case "left top":
case "to bottom right":
case "to right bottom":
return calculateGradientDirection(
Math.PI - parseTopRight(bounds),
bounds
);
case "bottom left":
case "left bottom":
case "to top right":
case "to right top":
return calculateGradientDirection(parseTopRight(bounds), bounds);
case "bottom right":
case "right bottom":
case "to top left":
case "to left top":
return calculateGradientDirection(
2 * Math.PI - parseTopRight(bounds),
bounds
);
case "top":
case "to bottom":
default:
return calculateGradientDirection(Math.PI, bounds);
}
};
const parsePercentageAngle = (angle: string, bounds: Bounds): Direction => {
const [left, top] = angle.split(" ").map(parseFloat);
const ratio = ((left / 100) * bounds.width) / ((top / 100) * bounds.height);
return calculateGradientDirection(
Math.atan(isNaN(ratio) ? 1 : ratio) + Math.PI / 2,
bounds
);
};
const transformObsoleteColorStops = (args: Array<string>): string[] => {
// @ts-expect-error
return (
args
.map((color) => color.match(FROM_TO_COLORSTOP))
// @ts-expect-error
.map((v: string[], index: number) => {
if (!v) {
return args[index];
}
switch (v[1]) {
case "from":
return `${v[4]} 0%`;
case "to":
return `${v[4]} 100%`;
case "color-stop":
if (v[3] === "%") {
return `${v[4]} ${v[2]}`;
}
return `${v[4]} ${parseFloat(v[2]) * 100}%`;
}
})
);
};
/**
* Given a CSS string, returns props for rendering with `expo-linear-gradient`.
*
* ```tsx
* fromCSS('linear-gradient(180deg, #ff008450 0%, #fca40040 25%, #ffff0030 40%, #00ff8a20 60%, #00cfff40 75%, #cc4cfa50 100%);')
* ```
*/
export function fromCSS(str: string) {
if (str.endsWith(";")) {
str = str.slice(0, -1);
}
const values = str?.match(/([a-zA-Z0-9_-]+)\((.*)\).*/);
if (!values) throw new Error("Invalid CSS Gradient function: " + str);
const [, method, argString] = values;
const args: string[] = [];
let buffer = "";
let openParens = 0;
for (let i = 0; i < argString.length; i++) {
const char = argString[i];
if (char === "," && openParens === 0) {
args.push(buffer.trim());
buffer = "";
} else {
buffer += char;
if (char === "(") {
openParens++;
} else if (char === ")") {
openParens--;
}
}
}
args.push(buffer.trim());
return parseGradient(
{
args,
method,
},
{ width: 1, height: 1 }
);
}