├── .eslintrc.js ├── .gitattributes ├── .gitignore ├── LICENSE ├── README.md ├── package.json ├── src └── index.ts ├── tsconfig.json └── yarn.lock /.eslintrc.js: -------------------------------------------------------------------------------- 1 | // @generated by expo-module-scripts 2 | module.exports = require('expo-module-scripts/eslintrc.base.js'); 3 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Serverless directories 108 | .serverless/ 109 | 110 | # FuseBox cache 111 | .fusebox/ 112 | 113 | # DynamoDB Local files 114 | .dynamodb/ 115 | 116 | # TernJS port file 117 | .tern-port 118 | 119 | # Stores VSCode versions used for testing VSCode extensions 120 | .vscode-test 121 | 122 | # yarn v2 123 | .yarn/cache 124 | .yarn/unplugged 125 | .yarn/build-state.yml 126 | .yarn/install-state.gz 127 | .pnp.* 128 | 129 | /build -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 evanbacon 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @bacons/css-to-expo-linear-gradient 2 | 3 | > Demo: [snack](https://snack.expo.dev/@bacon/bacons-css-to-expo-linear-gradient) 4 | 5 | Convert a CSS linear gradient function to `expo-linear-gradient` props. 6 | 7 | ## Add the package to your npm dependencies 8 | 9 | ``` 10 | yarn add @bacons/css-to-expo-linear-gradient 11 | ``` 12 | 13 | ## Usage 14 | 15 | ```tsx 16 | import { fromCSS } from "@bacons/css-to-expo-linear-gradient"; 17 | import { LinearGradient } from "expo-linear-gradient"; 18 | 19 | function App() { 20 | return ( 21 | 26 | ); 27 | } 28 | ``` 29 | 30 | ## Attribution 31 | 32 | Most of the code is adapted from [this project](https://github.com/niklasvh/html2canvas/tree/eeda86bd5e81fb4e97675fe9bee3d4d15899997f) which converts CSS linear gradients to canvas gradients. 33 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@bacons/css-to-expo-linear-gradient", 3 | "version": "1.3.0", 4 | "description": "Convert a CSS linear gradient function to expo-linear-gradient props", 5 | "main": "build/index.js", 6 | "scripts": { 7 | "build": "expo-module build", 8 | "clean": "expo-module clean", 9 | "lint": "expo-module lint", 10 | "test": "expo-module test", 11 | "prepare": "expo-module prepare", 12 | "prepublishOnly": "expo-module prepublishOnly", 13 | "expo-module": "expo-module" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "https://github.com/evanbacon/css-to-expo-linear-gradient.git" 18 | }, 19 | "peerDependencies": { 20 | "react-native": "*" 21 | }, 22 | "keywords": [ 23 | "expo", 24 | "expo-linear-gradient", 25 | "react-native" 26 | ], 27 | "author": "Evan Bacon", 28 | "license": "MIT", 29 | "devDependencies": { 30 | "expo-module-scripts": "^2.0.0" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | // From https://github.com/niklasvh/html2canvas/tree/eeda86bd5e81fb4e97675fe9bee3d4d15899997f 2 | 3 | type Bounds = { width: number; height: number }; 4 | type Direction = { 5 | x0: number; 6 | x1: number; 7 | y0: number; 8 | y1: number; 9 | }; 10 | 11 | const ANGLE = /([+-]?\d*\.?\d+)(deg|grad|rad|turn)/i; 12 | 13 | const SIDE_OR_CORNER = 14 | /^(to )?(left|top|right|bottom)( (left|top|right|bottom))?$/i; 15 | const PERCENTAGE_ANGLES = /^([+-]?\d*\.?\d+)% ([+-]?\d*\.?\d+)%$/i; 16 | const ENDS_WITH_LENGTH = /(px)$|(%$)|( 0$)/; 17 | const FROM_TO_COLORSTOP = 18 | /^(from|to|color-stop)\((?:([\d.]+)(%)?,\s*)?(.+?)\)$/i; 19 | 20 | const parseAngle = (angle: string): number | null => { 21 | const match = angle.match(ANGLE); 22 | 23 | if (match) { 24 | const value = parseFloat(match[1]); 25 | switch (match[2].toLowerCase()) { 26 | case "deg": 27 | return (Math.PI * value) / 180; 28 | case "grad": 29 | return (Math.PI / 200) * value; 30 | case "rad": 31 | return value; 32 | case "turn": 33 | return Math.PI * 2 * value; 34 | } 35 | } 36 | 37 | return null; 38 | }; 39 | 40 | const distance = (a: number, b: number): number => 41 | Math.sqrt(a * a + b * b); 42 | 43 | const parseGradient = ( 44 | { 45 | args, 46 | method, 47 | prefix, 48 | }: { args: Array; method: string; prefix?: string }, 49 | bounds: Bounds 50 | ) => { 51 | if (method === "linear-gradient") { 52 | return parseLinearGradient(args, bounds, !!prefix); 53 | } else if (method === "gradient" && args[0] === "linear") { 54 | // TODO handle correct angle 55 | return parseLinearGradient( 56 | ["to bottom"].concat(transformObsoleteColorStops(args.slice(3))), 57 | bounds, 58 | !!prefix 59 | ); 60 | } else if ( 61 | method === "radial-gradient" || 62 | (method === "gradient" && args[0] === "radial") 63 | ) { 64 | throw new Error("radial gradients are not supported"); 65 | } 66 | throw new Error("unknown gradient type: " + method); 67 | }; 68 | 69 | const parseColorStops = (args: Array, firstColorStopIndex: number) => { 70 | const colorStops: Array<{ color: string; stop: string | null }> = []; 71 | 72 | for (let i = firstColorStopIndex; i < args.length; i++) { 73 | const value = args[i]; 74 | const HAS_LENGTH = ENDS_WITH_LENGTH.test(value); 75 | const lastSpaceIndex = value.lastIndexOf(" "); 76 | const color = HAS_LENGTH ? value.substring(0, lastSpaceIndex) : value; 77 | const stop = HAS_LENGTH 78 | ? value.substring(lastSpaceIndex + 1) 79 | : i === firstColorStopIndex 80 | ? "0%" 81 | : i === args.length - 1 82 | ? "100%" 83 | : // fallback to evenly spaced color stops 84 | // prettier-ignore 85 | `${((i - firstColorStopIndex) / (args.length - firstColorStopIndex - 1)) * 100}%`; 86 | colorStops.push({ color, stop }); 87 | } 88 | 89 | const parsePosition = (p) => { 90 | const percentage = parseInt(p.replace(/%$/, ""), 10); 91 | const normal = percentage / 100; 92 | return normal; 93 | }; 94 | 95 | const colors: string[] = []; 96 | const locations: number[] = []; 97 | colorStops.forEach(({ color, stop }) => { 98 | colors.push(color); 99 | locations.push(parsePosition(stop)); 100 | }); 101 | 102 | colors.reverse(); 103 | 104 | return { colors, locations }; 105 | }; 106 | 107 | const parseLinearGradient = ( 108 | args: Array, 109 | bounds: Bounds, 110 | hasPrefix: boolean 111 | ) => { 112 | const angle = parseAngle(args[0]); 113 | const HAS_SIDE_OR_CORNER = SIDE_OR_CORNER.test(args[0]); 114 | const HAS_DIRECTION = 115 | HAS_SIDE_OR_CORNER || angle !== null || PERCENTAGE_ANGLES.test(args[0]); 116 | const direction = HAS_DIRECTION 117 | ? angle !== null 118 | ? calculateGradientDirection( 119 | // if there is a prefix, the 0° angle points due East (instead of North per W3C) 120 | hasPrefix ? angle - Math.PI * 0.5 : angle, 121 | bounds 122 | ) 123 | : HAS_SIDE_OR_CORNER 124 | ? parseSideOrCorner(args[0], bounds) 125 | : parsePercentageAngle(args[0], bounds) 126 | : calculateGradientDirection(Math.PI, bounds); 127 | const firstColorStopIndex = HAS_DIRECTION ? 1 : 0; 128 | 129 | return { 130 | ...parseColorStops(args, firstColorStopIndex), 131 | start: { x: direction.x0, y: direction.y0 }, 132 | end: { x: direction.x1, y: direction.y1 }, 133 | }; 134 | }; 135 | 136 | const calculateGradientDirection = ( 137 | radian: number, 138 | bounds: Bounds 139 | ): Direction => { 140 | const width = bounds.width; 141 | const height = bounds.height; 142 | const HALF_WIDTH = width * 0.5; 143 | const HALF_HEIGHT = height * 0.5; 144 | const lineLength = 145 | Math.abs(width * Math.sin(radian)) + Math.abs(height * Math.cos(radian)); 146 | const HALF_LINE_LENGTH = lineLength / 2; 147 | 148 | const x0 = HALF_WIDTH + Math.sin(radian) * HALF_LINE_LENGTH; 149 | const y0 = HALF_HEIGHT - Math.cos(radian) * HALF_LINE_LENGTH; 150 | const x1 = width - x0; 151 | const y1 = height - y0; 152 | 153 | return { x0, x1, y0, y1 }; 154 | }; 155 | 156 | const parseTopRight = (bounds: Bounds) => 157 | Math.acos(bounds.width / 2 / (distance(bounds.width, bounds.height) / 2)); 158 | 159 | const parseSideOrCorner = (side: string, bounds: Bounds): Direction => { 160 | switch (side) { 161 | case "bottom": 162 | case "to top": 163 | return calculateGradientDirection(0, bounds); 164 | case "left": 165 | case "to right": 166 | return calculateGradientDirection(Math.PI / 2, bounds); 167 | case "right": 168 | case "to left": 169 | return calculateGradientDirection((3 * Math.PI) / 2, bounds); 170 | case "top right": 171 | case "right top": 172 | case "to bottom left": 173 | case "to left bottom": 174 | return calculateGradientDirection( 175 | Math.PI + parseTopRight(bounds), 176 | bounds 177 | ); 178 | case "top left": 179 | case "left top": 180 | case "to bottom right": 181 | case "to right bottom": 182 | return calculateGradientDirection( 183 | Math.PI - parseTopRight(bounds), 184 | bounds 185 | ); 186 | case "bottom left": 187 | case "left bottom": 188 | case "to top right": 189 | case "to right top": 190 | return calculateGradientDirection(parseTopRight(bounds), bounds); 191 | case "bottom right": 192 | case "right bottom": 193 | case "to top left": 194 | case "to left top": 195 | return calculateGradientDirection( 196 | 2 * Math.PI - parseTopRight(bounds), 197 | bounds 198 | ); 199 | case "top": 200 | case "to bottom": 201 | default: 202 | return calculateGradientDirection(Math.PI, bounds); 203 | } 204 | }; 205 | 206 | const parsePercentageAngle = (angle: string, bounds: Bounds): Direction => { 207 | const [left, top] = angle.split(" ").map(parseFloat); 208 | const ratio = ((left / 100) * bounds.width) / ((top / 100) * bounds.height); 209 | 210 | return calculateGradientDirection( 211 | Math.atan(isNaN(ratio) ? 1 : ratio) + Math.PI / 2, 212 | bounds 213 | ); 214 | }; 215 | 216 | const transformObsoleteColorStops = (args: Array): string[] => { 217 | // @ts-expect-error 218 | return ( 219 | args 220 | .map((color) => color.match(FROM_TO_COLORSTOP)) 221 | // @ts-expect-error 222 | .map((v: string[], index: number) => { 223 | if (!v) { 224 | return args[index]; 225 | } 226 | 227 | switch (v[1]) { 228 | case "from": 229 | return `${v[4]} 0%`; 230 | case "to": 231 | return `${v[4]} 100%`; 232 | case "color-stop": 233 | if (v[3] === "%") { 234 | return `${v[4]} ${v[2]}`; 235 | } 236 | return `${v[4]} ${parseFloat(v[2]) * 100}%`; 237 | } 238 | }) 239 | ); 240 | }; 241 | 242 | /** 243 | * Given a CSS string, returns props for rendering with `expo-linear-gradient`. 244 | * 245 | * ```tsx 246 | * fromCSS('linear-gradient(180deg, #ff008450 0%, #fca40040 25%, #ffff0030 40%, #00ff8a20 60%, #00cfff40 75%, #cc4cfa50 100%);') 247 | * ``` 248 | */ 249 | export function fromCSS(str: string) { 250 | if (str.endsWith(";")) { 251 | str = str.slice(0, -1); 252 | } 253 | const values = str?.match(/([a-zA-Z0-9_-]+)\((.*)\).*/); 254 | if (!values) throw new Error("Invalid CSS Gradient function: " + str); 255 | 256 | const [, method, argString] = values; 257 | const args: string[] = []; 258 | 259 | let buffer = ""; 260 | let openParens = 0; 261 | 262 | for (let i = 0; i < argString.length; i++) { 263 | const char = argString[i]; 264 | 265 | if (char === "," && openParens === 0) { 266 | args.push(buffer.trim()); 267 | buffer = ""; 268 | } else { 269 | buffer += char; 270 | if (char === "(") { 271 | openParens++; 272 | } else if (char === ")") { 273 | openParens--; 274 | } 275 | } 276 | } 277 | args.push(buffer.trim()); 278 | 279 | return parseGradient( 280 | { 281 | args, 282 | method, 283 | }, 284 | { width: 1, height: 1 } 285 | ); 286 | } 287 | 288 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "expo-module-scripts/tsconfig.base", 3 | "compilerOptions": { 4 | "target": "es2019", 5 | "module": "commonjs", 6 | "outDir": "./build" 7 | }, 8 | "include": ["./src"], 9 | "exclude": ["**/__mocks__/*", "**/__tests__/*"] 10 | } 11 | --------------------------------------------------------------------------------