├── .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 |
--------------------------------------------------------------------------------