react-native-figma-shadow 0.1.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +152 -0
  3. package/android/CMakeLists.txt +30 -0
  4. package/android/build.gradle +76 -0
  5. package/android/gradle.properties +3 -0
  6. package/android/src/main/AndroidManifest.xml +1 -0
  7. package/android/src/main/java/com/figmashadow/FigmaShadowView.kt +224 -0
  8. package/android/src/main/java/com/figmashadow/FigmaShadowViewManager.kt +71 -0
  9. package/android/src/main/java/com/figmashadow/FigmaShadowViewPackage.kt +30 -0
  10. package/android/src/main/jni/cpp-adapter.cpp +77 -0
  11. package/cpp/figmashadow/Color.cpp +225 -0
  12. package/cpp/figmashadow/Color.h +14 -0
  13. package/cpp/figmashadow/FigmaShadow.cpp +114 -0
  14. package/cpp/figmashadow/FigmaShadow.h +30 -0
  15. package/cpp/figmashadow/Parser.cpp +150 -0
  16. package/cpp/figmashadow/Parser.h +16 -0
  17. package/cpp/figmashadow/Rasterizer.cpp +276 -0
  18. package/cpp/figmashadow/Rasterizer.h +21 -0
  19. package/cpp/figmashadow/Types.h +66 -0
  20. package/docs/ARCHITECTURE.md +67 -0
  21. package/docs/images/fs_drop.png +0 -0
  22. package/docs/images/fs_inset.png +0 -0
  23. package/docs/images/fs_offset.png +0 -0
  24. package/ios/FigmaShadowView.h +9 -0
  25. package/ios/FigmaShadowView.mm +207 -0
  26. package/lib/commonjs/FigmaShadowViewNativeComponent.js +10 -0
  27. package/lib/commonjs/FigmaShadowViewNativeComponent.js.map +1 -0
  28. package/lib/commonjs/index.js +106 -0
  29. package/lib/commonjs/index.js.map +1 -0
  30. package/lib/commonjs/package.json +1 -0
  31. package/lib/commonjs/parseShadow.js +145 -0
  32. package/lib/commonjs/parseShadow.js.map +1 -0
  33. package/lib/module/FigmaShadowViewNativeComponent.js +5 -0
  34. package/lib/module/FigmaShadowViewNativeComponent.js.map +1 -0
  35. package/lib/module/index.js +88 -0
  36. package/lib/module/index.js.map +1 -0
  37. package/lib/module/package.json +1 -0
  38. package/lib/module/parseShadow.js +139 -0
  39. package/lib/module/parseShadow.js.map +1 -0
  40. package/lib/typescript/src/FigmaShadowViewNativeComponent.d.ts +24 -0
  41. package/lib/typescript/src/FigmaShadowViewNativeComponent.d.ts.map +1 -0
  42. package/lib/typescript/src/index.d.ts +40 -0
  43. package/lib/typescript/src/index.d.ts.map +1 -0
  44. package/lib/typescript/src/parseShadow.d.ts +22 -0
  45. package/lib/typescript/src/parseShadow.d.ts.map +1 -0
  46. package/package.json +122 -0
  47. package/react-native-figma-shadow.podspec +25 -0
  48. package/react-native.config.js +19 -0
  49. package/src/FigmaShadowViewNativeComponent.ts +30 -0
  50. package/src/index.tsx +134 -0
  51. package/src/parseShadow.ts +134 -0
package/package.json ADDED
@@ -0,0 +1,122 @@
1
+ {
2
+ "name": "react-native-figma-shadow",
3
+ "version": "0.1.0-alpha.1",
4
+ "description": "Paste a CSS box-shadow from Figma and get a pixel-identical shadow on iOS and Android. New Architecture, zero peer dependencies.",
5
+ "main": "./lib/commonjs/index.js",
6
+ "module": "./lib/module/index.js",
7
+ "types": "./lib/typescript/src/index.d.ts",
8
+ "react-native": "src/index",
9
+ "source": "src/index",
10
+ "sideEffects": false,
11
+ "exports": {
12
+ ".": {
13
+ "source": "./src/index.tsx",
14
+ "types": "./lib/typescript/src/index.d.ts",
15
+ "react-native": "./src/index.tsx",
16
+ "import": "./lib/module/index.js",
17
+ "require": "./lib/commonjs/index.js",
18
+ "default": "./lib/commonjs/index.js"
19
+ },
20
+ "./package.json": "./package.json"
21
+ },
22
+ "files": [
23
+ "src",
24
+ "lib",
25
+ "android",
26
+ "ios",
27
+ "cpp",
28
+ "docs",
29
+ "*.podspec",
30
+ "react-native.config.js",
31
+ "!ios/build",
32
+ "!android/build",
33
+ "!android/.cxx",
34
+ "!cpp/test",
35
+ "!**/__tests__",
36
+ "!**/.*"
37
+ ],
38
+ "scripts": {
39
+ "test": "jest",
40
+ "typecheck": "tsc --noEmit",
41
+ "lint": "eslint \"**/*.{js,ts,tsx}\"",
42
+ "clean": "del-cli lib",
43
+ "prepare": "bob build",
44
+ "prepublishOnly": "npm run typecheck && npm test",
45
+ "cpp-test": "c++ -std=c++17 -O2 -Wall -Wextra -Icpp/figmashadow cpp/test/test_main.cpp cpp/figmashadow/*.cpp -o /tmp/fstest && /tmp/fstest"
46
+ },
47
+ "keywords": [
48
+ "react-native",
49
+ "ios",
50
+ "android",
51
+ "fabric",
52
+ "new-architecture",
53
+ "shadow",
54
+ "box-shadow",
55
+ "elevation",
56
+ "figma",
57
+ "css"
58
+ ],
59
+ "repository": {
60
+ "type": "git",
61
+ "url": "git+https://github.com/RaoMK/react-native-figma-shadow.git"
62
+ },
63
+ "author": "RaoMK <mowglijuddi@gmail.com>",
64
+ "license": "MIT",
65
+ "bugs": {
66
+ "url": "https://github.com/RaoMK/react-native-figma-shadow/issues"
67
+ },
68
+ "homepage": "https://github.com/RaoMK/react-native-figma-shadow#readme",
69
+ "publishConfig": {
70
+ "registry": "https://registry.npmjs.org/",
71
+ "access": "public",
72
+ "tag": "alpha"
73
+ },
74
+ "devDependencies": {
75
+ "@react-native/babel-preset": "0.76.0",
76
+ "@react-native/eslint-config": "0.76.0",
77
+ "@types/jest": "^29.5.12",
78
+ "@types/react": "^18.2.44",
79
+ "del-cli": "^5.1.0",
80
+ "eslint": "^8.57.0",
81
+ "jest": "^29.7.0",
82
+ "react": "18.3.1",
83
+ "react-native": "0.76.0",
84
+ "react-native-builder-bob": "^0.30.2",
85
+ "typescript": "^5.3.3"
86
+ },
87
+ "peerDependencies": {
88
+ "react": ">=18.2.0",
89
+ "react-native": ">=0.76.0"
90
+ },
91
+ "engines": {
92
+ "node": ">=18"
93
+ },
94
+ "jest": {
95
+ "preset": "react-native",
96
+ "modulePathIgnorePatterns": [
97
+ "<rootDir>/lib/"
98
+ ]
99
+ },
100
+ "react-native-builder-bob": {
101
+ "source": "src",
102
+ "output": "lib",
103
+ "targets": [
104
+ "commonjs",
105
+ "module",
106
+ [
107
+ "typescript",
108
+ {
109
+ "project": "tsconfig.build.json"
110
+ }
111
+ ]
112
+ ]
113
+ },
114
+ "codegenConfig": {
115
+ "name": "RNFigmaShadowSpec",
116
+ "type": "components",
117
+ "jsSrcsDir": "src",
118
+ "android": {
119
+ "javaPackageName": "com.figmashadow"
120
+ }
121
+ }
122
+ }
@@ -0,0 +1,25 @@
1
+ require "json"
2
+
3
+ package = JSON.parse(File.read(File.join(__dir__, "package.json")))
4
+
5
+ Pod::Spec.new do |s|
6
+ s.name = "react-native-figma-shadow"
7
+ s.version = package["version"]
8
+ s.summary = package["description"]
9
+ s.homepage = package["homepage"]
10
+ s.license = package["license"]
11
+ s.authors = package["author"]
12
+
13
+ s.platforms = { :ios => "13.4" }
14
+ s.source = { :git => "https://github.com/RaoMK/react-native-figma-shadow.git", :tag => "#{s.version}" }
15
+
16
+ s.source_files = "ios/**/*.{h,m,mm,cpp}", "cpp/figmashadow/**/*.{h,cpp}"
17
+ s.private_header_files = "cpp/figmashadow/**/*.h"
18
+
19
+ s.pod_target_xcconfig = {
20
+ "CLANG_CXX_LANGUAGE_STANDARD" => "c++17",
21
+ "HEADER_SEARCH_PATHS" => "\"$(PODS_TARGET_SRCROOT)/cpp/figmashadow\" \"$(PODS_TARGET_SRCROOT)/cpp\""
22
+ }
23
+
24
+ install_modules_dependencies(s)
25
+ end
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The shadow raster lives in a standalone `.so` that this package builds itself
3
+ * (see android/build.gradle + android/CMakeLists.txt) and loads with
4
+ * `System.loadLibrary`, so we deliberately do NOT set `cmakeListsPath` here —
5
+ * that would make the host app try to build the same sources a second time.
6
+ *
7
+ * The Fabric component descriptor is generated from `codegenConfig` in
8
+ * package.json by the host app's build.
9
+ */
10
+ module.exports = {
11
+ dependency: {
12
+ platforms: {
13
+ android: {
14
+ componentDescriptors: ['FigmaShadowViewComponentDescriptor'],
15
+ },
16
+ ios: {},
17
+ },
18
+ },
19
+ };
@@ -0,0 +1,30 @@
1
+ import type { ViewProps } from 'react-native';
2
+ import type { Float, WithDefault } from 'react-native/Libraries/Types/CodegenTypes';
3
+ import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent';
4
+
5
+ export interface NativeProps extends ViewProps {
6
+ /** A CSS `box-shadow` value, exactly as copied from Figma / a stylesheet. */
7
+ shadow?: string;
8
+
9
+ /** Optional fill painted inside the content box, below inset shadows. */
10
+ fillColor?: string;
11
+
12
+ borderTopLeftRadius?: WithDefault<Float, 0>;
13
+ borderTopRightRadius?: WithDefault<Float, 0>;
14
+ borderBottomRightRadius?: WithDefault<Float, 0>;
15
+ borderBottomLeftRadius?: WithDefault<Float, 0>;
16
+
17
+ /** Bleed insets (device-independent px) computed on the JS side. */
18
+ bleedLeft?: WithDefault<Float, 0>;
19
+ bleedTop?: WithDefault<Float, 0>;
20
+ bleedRight?: WithDefault<Float, 0>;
21
+ bleedBottom?: WithDefault<Float, 0>;
22
+
23
+ /** `PixelRatio.get()` — passed so the raster matches the screen density. */
24
+ pixelRatio?: WithDefault<Float, 1>;
25
+
26
+ /** Use the slower, exact rounded-corner quadrature. */
27
+ highQuality?: WithDefault<boolean, false>;
28
+ }
29
+
30
+ export default codegenNativeComponent<NativeProps>('FigmaShadowView');
package/src/index.tsx ADDED
@@ -0,0 +1,134 @@
1
+ import React, { useMemo } from 'react';
2
+ import {
3
+ PixelRatio,
4
+ StyleSheet,
5
+ View,
6
+ type StyleProp,
7
+ type ViewProps,
8
+ type ViewStyle,
9
+ } from 'react-native';
10
+
11
+ import FigmaShadowView from './FigmaShadowViewNativeComponent';
12
+ import { computeBleed, parseBoxShadow, type EdgeInsets } from './parseShadow';
13
+
14
+ export type { EdgeInsets, ParsedShadowLayer } from './parseShadow';
15
+ export { parseBoxShadow, computeBleed } from './parseShadow';
16
+
17
+ export interface ShadowProps extends Omit<ViewProps, 'style'> {
18
+ /**
19
+ * A CSS `box-shadow` value — the exact string Figma's "Copy as CSS" produces,
20
+ * or what you already use on the web. Supports multiple comma-separated
21
+ * shadows, `inset`, spread, and any CSS color. Renders identically on iOS and
22
+ * Android.
23
+ */
24
+ shadow?: string;
25
+
26
+ /** Uniform corner radius for the shadow shape. Match your child's radius. */
27
+ borderRadius?: number;
28
+ borderTopLeftRadius?: number;
29
+ borderTopRightRadius?: number;
30
+ borderBottomRightRadius?: number;
31
+ borderBottomLeftRadius?: number;
32
+
33
+ /**
34
+ * Fill painted inside the content box, below any inset shadow and below the
35
+ * children. Use this (instead of a background on the child) when you want an
36
+ * inset shadow to sit above the fill, like CSS.
37
+ */
38
+ backgroundColor?: string;
39
+
40
+ /** Opt into the slower, exact rounded-corner convolution. */
41
+ highQuality?: boolean;
42
+
43
+ /** Applied to the outer layout box. */
44
+ style?: StyleProp<ViewStyle>;
45
+
46
+ children?: React.ReactNode;
47
+ }
48
+
49
+ function resolveRadii(props: ShadowProps) {
50
+ const base = props.borderRadius ?? 0;
51
+ return {
52
+ tl: props.borderTopLeftRadius ?? base,
53
+ tr: props.borderTopRightRadius ?? base,
54
+ br: props.borderBottomRightRadius ?? base,
55
+ bl: props.borderBottomLeftRadius ?? base,
56
+ };
57
+ }
58
+
59
+ /**
60
+ * Wraps its children and paints a pixel-consistent CSS-style shadow behind them
61
+ * on both platforms.
62
+ *
63
+ * Layout is neutral: the shadow bleeds into space the component reserves with
64
+ * negative margins, so siblings are laid out as if only the children were there.
65
+ */
66
+ export function Shadow(props: ShadowProps) {
67
+ const {
68
+ shadow = '',
69
+ backgroundColor,
70
+ highQuality = false,
71
+ style,
72
+ children,
73
+ // Pulled out so they are not spread onto the native view (which only takes
74
+ // per-corner radii); `backgroundColor` is forwarded below as `fillColor` to
75
+ // avoid colliding with RN's built-in view prop.
76
+ borderRadius: _borderRadius,
77
+ borderTopLeftRadius: _btl,
78
+ borderTopRightRadius: _btr,
79
+ borderBottomRightRadius: _bbr,
80
+ borderBottomLeftRadius: _bbl,
81
+ ...rest
82
+ } = props;
83
+
84
+ const layers = useMemo(() => parseBoxShadow(shadow), [shadow]);
85
+ const bleed: EdgeInsets = useMemo(() => computeBleed(layers), [layers]);
86
+ const radii = resolveRadii(props);
87
+
88
+ const geometry: ViewStyle = {
89
+ marginLeft: -bleed.left,
90
+ marginTop: -bleed.top,
91
+ marginRight: -bleed.right,
92
+ marginBottom: -bleed.bottom,
93
+ paddingLeft: bleed.left,
94
+ paddingTop: bleed.top,
95
+ paddingRight: bleed.right,
96
+ paddingBottom: bleed.bottom,
97
+ };
98
+
99
+ return (
100
+ <View style={[styles.outer, style]}>
101
+ <FigmaShadowView
102
+ {...rest}
103
+ shadow={shadow}
104
+ fillColor={backgroundColor}
105
+ highQuality={highQuality}
106
+ borderTopLeftRadius={radii.tl}
107
+ borderTopRightRadius={radii.tr}
108
+ borderBottomRightRadius={radii.br}
109
+ borderBottomLeftRadius={radii.bl}
110
+ bleedLeft={bleed.left}
111
+ bleedTop={bleed.top}
112
+ bleedRight={bleed.right}
113
+ bleedBottom={bleed.bottom}
114
+ pixelRatio={PixelRatio.get()}
115
+ style={[styles.native, geometry]}
116
+ >
117
+ {children}
118
+ </FigmaShadowView>
119
+ </View>
120
+ );
121
+ }
122
+
123
+ const styles = StyleSheet.create({
124
+ outer: {
125
+ // The shadow bleeds past this layout box; an ancestor with `overflow:
126
+ // hidden` will clip it, exactly as it would in CSS.
127
+ overflow: 'visible',
128
+ },
129
+ native: {
130
+ overflow: 'visible',
131
+ },
132
+ });
133
+
134
+ export default Shadow;
@@ -0,0 +1,134 @@
1
+ /**
2
+ * A minimal `box-shadow` parser used on the JS side purely to compute how far
3
+ * the shadow bleeds past the element box. The authoritative parse (colors,
4
+ * rendering) happens in the shared C++ core; this only needs geometry.
5
+ */
6
+
7
+ export interface ParsedShadowLayer {
8
+ offsetX: number;
9
+ offsetY: number;
10
+ blur: number;
11
+ spread: number;
12
+ inset: boolean;
13
+ }
14
+
15
+ export interface EdgeInsets {
16
+ left: number;
17
+ top: number;
18
+ right: number;
19
+ bottom: number;
20
+ }
21
+
22
+ // A Gaussian with sigma = blur / 2; 3 sigma (= 1.5 * blur) captures 99.7% of it.
23
+ const BLUR_EXTENT_FACTOR = 1.5;
24
+
25
+ function splitTopLevel(input: string, delimiter: string): string[] {
26
+ const out: string[] = [];
27
+ let depth = 0;
28
+ let current = '';
29
+ for (const ch of input) {
30
+ if (ch === '(') {depth++;}
31
+ else if (ch === ')') {depth = Math.max(0, depth - 1);}
32
+
33
+ if (ch === delimiter && depth === 0) {
34
+ out.push(current);
35
+ current = '';
36
+ } else {
37
+ current += ch;
38
+ }
39
+ }
40
+ out.push(current);
41
+ return out;
42
+ }
43
+
44
+ function tokenize(layer: string): string[] {
45
+ const out: string[] = [];
46
+ let depth = 0;
47
+ let current = '';
48
+ for (const ch of layer) {
49
+ if (ch === '(') {depth++;}
50
+ else if (ch === ')') {depth = Math.max(0, depth - 1);}
51
+
52
+ if (/\s/.test(ch) && depth === 0) {
53
+ if (current) {
54
+ out.push(current);
55
+ current = '';
56
+ }
57
+ } else {
58
+ current += ch;
59
+ }
60
+ }
61
+ if (current) {out.push(current);}
62
+ return out;
63
+ }
64
+
65
+ function parseLength(token: string): number | null {
66
+ const match = /^(-?\d*\.?\d+)(px|dp|dip|pt)?$/i.exec(token.trim());
67
+ if (!match) {return null;}
68
+ return parseFloat(match[1]);
69
+ }
70
+
71
+ export function parseBoxShadow(input: string | undefined | null): ParsedShadowLayer[] {
72
+ if (!input) {return [];}
73
+ let s = input.trim();
74
+
75
+ const colon = s.indexOf(':');
76
+ if (colon !== -1) {
77
+ const head = s.slice(0, colon).trim().toLowerCase();
78
+ if (head === 'box-shadow' || head === 'boxshadow') {s = s.slice(colon + 1).trim();}
79
+ }
80
+ if (s.endsWith(';')) {s = s.slice(0, -1).trim();}
81
+ if (!s || s.toLowerCase() === 'none') {return [];}
82
+
83
+ const layers: ParsedShadowLayer[] = [];
84
+ for (const part of splitTopLevel(s, ',')) {
85
+ const trimmed = part.trim();
86
+ if (!trimmed) {continue;}
87
+
88
+ let inset = false;
89
+ const lengths: number[] = [];
90
+ for (const token of tokenize(trimmed)) {
91
+ if (token.toLowerCase() === 'inset') {
92
+ inset = true;
93
+ continue;
94
+ }
95
+ const len = parseLength(token);
96
+ if (len !== null) {lengths.push(len);}
97
+ // anything else (a color) is irrelevant to bleed
98
+ }
99
+ if (lengths.length < 2) {continue;}
100
+
101
+ layers.push({
102
+ offsetX: lengths[0],
103
+ offsetY: lengths[1],
104
+ blur: Math.max(0, lengths[2] ?? 0),
105
+ spread: lengths[3] ?? 0,
106
+ inset,
107
+ });
108
+ }
109
+ return layers;
110
+ }
111
+
112
+ export function computeBleed(layers: ParsedShadowLayer[]): EdgeInsets {
113
+ const bleed: EdgeInsets = { left: 0, top: 0, right: 0, bottom: 0 };
114
+
115
+ for (const layer of layers) {
116
+ if (layer.inset) {continue;}
117
+ const extent = layer.blur * BLUR_EXTENT_FACTOR + Math.max(0, layer.spread);
118
+ bleed.right = Math.max(bleed.right, extent + Math.max(0, layer.offsetX));
119
+ bleed.left = Math.max(bleed.left, extent + Math.max(0, -layer.offsetX));
120
+ bleed.bottom = Math.max(bleed.bottom, extent + Math.max(0, layer.offsetY));
121
+ bleed.top = Math.max(bleed.top, extent + Math.max(0, -layer.offsetY));
122
+ }
123
+
124
+ return {
125
+ left: Math.ceil(bleed.left),
126
+ top: Math.ceil(bleed.top),
127
+ right: Math.ceil(bleed.right),
128
+ bottom: Math.ceil(bleed.bottom),
129
+ };
130
+ }
131
+
132
+ export function hasVisibleShadow(layers: ParsedShadowLayer[]): boolean {
133
+ return layers.length > 0;
134
+ }