From 3dbf069cc68adcf735b72d7e290ac8b22ebd8011 Mon Sep 17 00:00:00 2001 From: Jacek Pudysz Date: Wed, 25 Oct 2023 16:29:57 +0200 Subject: [PATCH] feat: replace react native web normalizer with built in one --- src/__tests__/normalizer.spec.ts | 347 +++++++++++++++++++++++++++++++ src/types/index.ts | 1 + src/types/normalizer.ts | 29 +++ src/utils/index.ts | 1 + src/utils/normalizeStyles.web.ts | 63 ++---- src/utils/normalizer.ts | 105 ++++++++++ 6 files changed, 504 insertions(+), 42 deletions(-) create mode 100644 src/__tests__/normalizer.spec.ts create mode 100644 src/types/normalizer.ts create mode 100644 src/utils/normalizer.ts diff --git a/src/__tests__/normalizer.spec.ts b/src/__tests__/normalizer.spec.ts new file mode 100644 index 00000000..f35da292 --- /dev/null +++ b/src/__tests__/normalizer.spec.ts @@ -0,0 +1,347 @@ +import { preprocessor, normalizeNumericValue, normalizeColor } from '../utils' +import type { BoxShadow, TextShadow, Transforms } from '../types' + +describe('Normalizer', () => { + describe('Box Shadow', () => { + it ('should correctly convert all the RN shadows to BoxShadow', () => { + const styles: Array = [ + { + shadowRadius: 3, + shadowColor: '#000', + shadowOffset: { + width: 3, + height: 2 + }, + shadowOpacity: 0.4 + }, + { + shadowRadius: 1, + shadowColor: '#abcabc', + shadowOffset: { + width: 0, + height: 0 + }, + shadowOpacity: 1 + }, + { + shadowRadius: 0, + shadowColor: '#7f11e0', + shadowOffset: { + width: 0, + height: 1 + }, + shadowOpacity: 0.3 + }, + { + shadowRadius: 3, + shadowColor: '#FF5733', + shadowOffset: { + width: -4, + height: -3 + }, + shadowOpacity: 0.5 + }, + { + shadowRadius: 0, + shadowColor: '#000000', + shadowOffset: { + width: 0, + height: 0 + }, + shadowOpacity: 0 + }, + { + shadowRadius: 20, + shadowColor: 'orange', + shadowOffset: { + width: 50, + height: 50 + }, + shadowOpacity: 1 + }, + { + shadowRadius: 3, + shadowColor: '#FF5733CC', + shadowOffset: { + width: 0, + height: -10 + }, + shadowOpacity: 0.5 + } + ] + const results: Array = [ + '3px 2px 3px rgba(0,0,0,0.4)', + '0 0 1px rgba(171,202,188,1)', + '0 1px 0 rgba(127,17,224,0.3)', + '-4px -3px 3px rgba(255,87,51,0.5)', + '0 0 0 rgba(0,0,0,0)', + '50px 50px 20px orange', + '0 -10px 3px rgba(255,87,51,0.8)' + ] + + styles.forEach((style, index) => { + expect(preprocessor.createBoxShadowValue(style)).toEqual(results[index]) + }) + }) + }) + + describe('Text Shadow', () => { + it ('should correctly convert all the RN text shadows to TextShadow', () => { + const styles: Array = [ + { + textShadowColor: '#fff', + textShadowOffset: { + width: 4, + height: 5 + }, + textShadowRadius: 2 + }, + { + textShadowColor: '#000000', + textShadowOffset: { + width: 3, + height: 4 + }, + textShadowRadius: 5 + }, + { + textShadowColor: '#FF5733', + textShadowOffset: { + width: -1, + height: -1 + }, + textShadowRadius: 0 + }, + { + textShadowColor: 'red', + textShadowOffset: { + width: 0, + height: 0 + }, + textShadowRadius: 0 + }, + { + textShadowColor: '#FF5733CC', + textShadowOffset: { + width: -5, + height: -5 + }, + textShadowRadius: 0 + } + ] + const results: Array = [ + '4px 5px 2px rgba(255,255,255,1)', + '3px 4px 5px rgba(0,0,0,1)', + '-1px -1px 0 rgba(255,87,51,1)', + '0 0 0 red', + '-5px -5px 0 rgba(255,87,51,0.8)' + ] + + styles.forEach((style, index) => { + expect(preprocessor.createTextShadowValue(style)).toEqual(results[index]) + }) + }) + }) + + describe('Transforms', () => { + it ('should correctly convert all the RN transforms to CSS transforms', () => { + const styles: Array = [ + [ + { + scaleX: 1 + }, + { + rotateX: '30deg' + } + ], + [ + { + scaleY: 0.5 + }, + { + rotateY: '45deg' + }, + { + translateX: 100 + } + ], + [ + { + matrix: [1, 0, 0, 1, 30, 50] + } + ], + [ + { + perspective: 18 + }, + { + rotateZ: '0.785398rad' + }, + { + skewX: '45deg' + } + ], + [ + { + rotate: '0.5turn' + }, + { + translateX: 20 + }, + { + scale: 2.5 + }, + { + skewX: '30deg' + }, + { + skewY: '1.07rad' + } + ] + ] + const results: Array = [ + 'scaleX(1) rotateX(30deg)', + 'scaleY(0.5) rotateY(45deg) translateX(100px)', + 'matrix(1,0,0,1,30,50)', + 'perspective(18px) rotateZ(0.785398rad) skewX(45deg)', + 'rotate(0.5turn) translateX(20px) scale(2.5) skewX(30deg) skewY(1.07rad)' + ] + + styles.forEach((style, index) => { + expect(preprocessor.createTransformValue(style)).toEqual(results[index]) + }) + }) + }) + + describe('normalizeNumericValue', () => { + it('should normalize numeric values', () => { + const values: Array = [ + 5, + 6, + 12.5, + 17, + -100 + ] + + values.forEach(value => { + expect(normalizeNumericValue(value)).toEqual(`${value}px`) + }) + }) + + it('should not normalize numeric values equal to 0', () => { + const value = 0 + + expect(normalizeNumericValue(value)).toEqual(value) + }) + }) + + describe('normalizeColor', () => { + it('should not normalize string colors', () => { + const values: Array = [ + 'red', + 'orange', + 'pink', + 'purple', + 'black' + ] + + values.forEach((value, index) => { + expect(normalizeColor(value)).toEqual(values[index]) + }) + }) + + it('should handle hex values with 3 chars', () => { + const values: Array = [ + '#f00', + '#0f0', + '#00f', + '#ff0', + '#f0f', + '#0ff', + '#fff', + '#abc', + '#123', + '#456' + ] + const results: Array = [ + 'rgba(255,0,0,1)', + 'rgba(0,255,0,1)', + 'rgba(0,0,255,1)', + 'rgba(255,255,0,1)', + 'rgba(255,0,255,1)', + 'rgba(0,255,255,1)', + 'rgba(255,255,255,1)', + 'rgba(170,187,204,1)', + 'rgba(17,34,51,1)', + 'rgba(68,85,102,1)' + ] + + values.forEach((value, index) => { + expect(normalizeColor(value)).toEqual(results[index]) + }) + }) + + it('should handle hex values with 6 chars', () => { + const values: Array = [ + '#ff0000', + '#00ff00', + '#0000ff', + '#ffff00', + '#ff00ff', + '#00ffff', + '#ffffff', + '#aabbcc', + '#112233', + '#445566' + ] + const results: Array = [ + 'rgba(255,0,0,1)', + 'rgba(0,255,0,1)', + 'rgba(0,0,255,1)', + 'rgba(255,255,0,1)', + 'rgba(255,0,255,1)', + 'rgba(0,255,255,1)', + 'rgba(255,255,255,1)', + 'rgba(170,187,204,1)', + 'rgba(17,34,51,1)', + 'rgba(68,85,102,1)' + ] + + values.forEach((value, index) => { + expect(normalizeColor(value)).toEqual(results[index]) + }) + }) + + it('should handle hex values with 8 chars', () => { + const values: Array = [ + '#ff0000ff', + '#00ff00ff', + '#0000ffff', + '#ffff00ff', + '#ff00ffff', + '#00ffffff', + '#ffffffff', + '#aabbccdd', + '#11223344', + '#44556677' + ] + const results: Array = [ + 'rgba(255,0,0,1)', + 'rgba(0,255,0,1)', + 'rgba(0,0,255,1)', + 'rgba(255,255,0,1)', + 'rgba(255,0,255,1)', + 'rgba(0,255,255,1)', + 'rgba(255,255,255,1)', + 'rgba(170,187,204,0.8666666666666667)', + 'rgba(17,34,51,0.26666666666666666)', + 'rgba(68,85,102,0.4666666666666667)' + ] + + values.forEach((value, index) => { + expect(normalizeColor(value)).toEqual(results[index]) + }) + }) + + }) +}) diff --git a/src/types/index.ts b/src/types/index.ts index a4bb14e4..9af02bba 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,3 +1,4 @@ +export * from './normalizer' export type { CustomNamedStyles } from './core' export type { ScreenSize, diff --git a/src/types/normalizer.ts b/src/types/normalizer.ts new file mode 100644 index 00000000..e4bb72f1 --- /dev/null +++ b/src/types/normalizer.ts @@ -0,0 +1,29 @@ +import type { ShadowStyleIOS, TextStyle, TransformsStyle } from 'react-native' + +type TransformArrayElement = T extends Array ? U : never +type BoxShadow = Required +type TextShadow = Required> +type Transforms = Array> + +type NormalizedBoxShadow = { + shadowColor: undefined, + shadowOffset: undefined, + shadowOpacity: undefined, + shadowRadius: undefined, + boxShadow?: string +} + +type NormalizedTextShadow = { + textShadowColor: undefined + textShadowOffset: undefined + textShadowRadius: undefined, + textShadow?: string +} + +export type { + BoxShadow, + TextShadow, + Transforms, + NormalizedBoxShadow, + NormalizedTextShadow +} diff --git a/src/utils/index.ts b/src/utils/index.ts index e5b4f58d..0cff90a2 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,4 +1,5 @@ export { normalizeStyles } from './normalizeStyles' +export * from './normalizer' export { getBreakpointFromScreenWidth, sortAndValidateBreakpoints, getValueForBreakpoint } from './breakpoints' export { proxifyFunction, parseStyle } from './styles' export { diff --git a/src/utils/normalizeStyles.web.ts b/src/utils/normalizeStyles.web.ts index 2806bb77..e29fde6a 100644 --- a/src/utils/normalizeStyles.web.ts +++ b/src/utils/normalizeStyles.web.ts @@ -1,29 +1,8 @@ import { warn } from './common' +import { preprocessor } from './normalizer' +import type { NormalizedBoxShadow, NormalizedTextShadow, BoxShadow, TextShadow, Transforms } from '../types' -const preprocessor: Preprocessor = require('react-native-web/src/exports/StyleSheet/preprocess.js') - -type Preprocessor = { - createTextShadowValue(styles: any): T, - createBoxShadowValue(styles: any): T, - createTransformValue(transforms: any): T, -} - -type NormalizedBoxShadow = { - shadowColor: undefined, - shadowOffset: undefined, - shadowOpacity: undefined, - shadowRadius: undefined, - boxShadow?: string -} - -type NormalizedTextShadow = { - textShadowColor: undefined - textShadowOffset: undefined - textShadowRadius: undefined, - textShadow?: string -} - -const normalizeBoxShadow = (styles: T): NormalizedBoxShadow => { +const normalizeBoxShadow = (style: T): NormalizedBoxShadow => { const requiredBoxShadowProperties = [ 'shadowColor', 'shadowOffset', @@ -31,7 +10,7 @@ const normalizeBoxShadow = (styles: T): NormalizedBoxShadow => { 'shadowRadius' ] - if (!requiredBoxShadowProperties.every(prop => prop in styles)) { + if (!requiredBoxShadowProperties.every(prop => prop in style)) { warn(`can't apply box shadow as you miss at least one of these properties: ${requiredBoxShadowProperties.join(', ')}`) return { @@ -43,7 +22,7 @@ const normalizeBoxShadow = (styles: T): NormalizedBoxShadow => { } return { - boxShadow: preprocessor.createBoxShadowValue(styles), + boxShadow: preprocessor.createBoxShadowValue(style), shadowColor: undefined, shadowOffset: undefined, shadowOpacity: undefined, @@ -51,14 +30,14 @@ const normalizeBoxShadow = (styles: T): NormalizedBoxShadow => { } } -const normalizeTextShadow = (styles: T): NormalizedTextShadow => { +const normalizeTextShadow = (style: T): NormalizedTextShadow => { const requiredTextShadowProperties = [ 'textShadowColor', 'textShadowOffset', 'textShadowRadius' ] - if (!requiredTextShadowProperties.every(prop => prop in styles)) { + if (!requiredTextShadowProperties.every(prop => prop in style)) { warn(`can't apply text shadow as you miss at least one of these properties: ${requiredTextShadowProperties.join(', ')}`) return { @@ -69,33 +48,33 @@ const normalizeTextShadow = (styles: T): NormalizedTextShadow => { } return { - textShadow: preprocessor.createTextShadowValue(styles), + textShadow: preprocessor.createTextShadowValue(style), textShadowColor: undefined, textShadowOffset: undefined, textShadowRadius: undefined } } -export const normalizeStyles = (styles: T): T => { - const normalizedTransform = ('transform' in styles && Array.isArray(styles.transform)) - ? { transform: preprocessor.createTransformValue(styles.transform) } +export const normalizeStyles = (style: T): T => { + const normalizedTransform = ('transform' in style && Array.isArray(style.transform)) + ? { transform: preprocessor.createTransformValue(style.transform) } : {} const normalizedBoxShadow = ( - 'shadowColor' in styles || - 'shadowOffset' in styles || - 'shadowOpacity' in styles || - 'shadowRadius' in styles - ) ? normalizeBoxShadow(styles) : {} + 'shadowColor' in style || + 'shadowOffset' in style || + 'shadowOpacity' in style || + 'shadowRadius' in style + ) ? normalizeBoxShadow(style as BoxShadow) : {} const normalizedTextShadow = ( - 'textShadowColor' in styles || - 'textShadowOffset' in styles || - 'textShadowRadius' in styles - ) ? normalizeTextShadow(styles) : {} + 'textShadowColor' in style || + 'textShadowOffset' in style || + 'textShadowRadius' in style + ) ? normalizeTextShadow(style as TextShadow) : {} return { - ...styles, + ...style, ...normalizedTransform, ...normalizedBoxShadow, ...normalizedTextShadow diff --git a/src/utils/normalizer.ts b/src/utils/normalizer.ts new file mode 100644 index 00000000..6ca3c011 --- /dev/null +++ b/src/utils/normalizer.ts @@ -0,0 +1,105 @@ +// based on react-native-web normalizer +// https://github.com/necolas/react-native-web +import type { TextShadow, Transforms, BoxShadow } from '../types' + +type Preprocessor = { + createTextShadowValue(style: TextShadow): string, + createBoxShadowValue(style: Required): string, + createTransformValue(transforms: Required): string, +} + +// for now supports +// hex colors (3, 6, 8) chars +// colors like orange red etc. +export const normalizeColor = (color: string, opacity: number = 1) => { + if (!color.startsWith('#')) { + return color + } + + if (color.length === 9) { + const [r, g, b, a] = color + .slice(1) + .split(/(?=(?:..)*$)/) + .map(x => parseInt(x, 16)) + .filter(num => !isNaN(num)) + + return `rgba(${r},${g},${b},${(a as number) / 255})` + } + + const sanitizedHex = color.length === 4 + ? color + .slice(1) + .split('') + .map(char => `${char}${char}`) + .join('') + : color.slice(1) + + return sanitizedHex + .split(/(?=(?:..)*$)/) + .map(x => parseInt(x, 16)) + .filter(num => !isNaN(num)) + .reduce((acc, color) => `${acc}${color},`, 'rgba(') + .concat(`${opacity})`) +} + +export const normalizeNumericValue = (value: number) => value ? `${value}px` : value +const normalizeTransform = (key: string, value: number | string) => { + if (key.includes('scale')) { + return value + } + + if (typeof value === 'number') { + return normalizeNumericValue(value) + } + + return value +} + +const createTextShadowValue = (style: TextShadow) => { + // at this point every prop is present + const { textShadowColor, textShadowOffset, textShadowRadius } = style + const offsetX = normalizeNumericValue(textShadowOffset.width) + const offsetY = normalizeNumericValue(textShadowOffset.height) + const radius = normalizeNumericValue(textShadowRadius) + const color = normalizeColor(textShadowColor as string) + + return `${offsetX} ${offsetY} ${radius} ${color}` +} + +const createBoxShadowValue = (style: BoxShadow) => { + // at this point every prop is present + const { shadowColor, shadowOffset, shadowOpacity, shadowRadius } = style + const offsetX = normalizeNumericValue(shadowOffset.width) + const offsetY = normalizeNumericValue(shadowOffset.height) + const radius = normalizeNumericValue(shadowRadius) + const color = normalizeColor(shadowColor as string, shadowOpacity as number) + + return `${offsetX} ${offsetY} ${radius} ${color}` +} + +const createTransformValue = (transforms: Transforms) => transforms + .map(transform => { + const [key] = Object.keys(transform) + + if (!key) { + return undefined + } + + const value = transform[key as keyof typeof transform] + + switch(key) { + case 'matrix': + case 'matrix3d': + return `${key}(${(value as Array).join(',')})` + default: + return `${key}(${normalizeTransform(key, value)})` + } + }) + .filter(Boolean) + .join(' ') + +export const preprocessor: Preprocessor = { + createTextShadowValue, + createBoxShadowValue, + createTransformValue +}