exodeui-react-native 1.0.0 → 1.0.2

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.
package/index.js ADDED
@@ -0,0 +1 @@
1
+ export * from './src/index';
package/package.json CHANGED
@@ -1,14 +1,16 @@
1
1
  {
2
2
  "name": "exodeui-react-native",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "React Native runtime for ExodeUI animations",
5
- "main": "src/index.tsx",
5
+ "main": "index.js",
6
6
  "files": [
7
7
  "src",
8
+ "index.js",
8
9
  "README.md"
9
10
  ],
10
11
  "scripts": {
11
- "test": "echo \"Error: no test specified\" && exit 1"
12
+ "test": "vitest run",
13
+ "test:watch": "vitest"
12
14
  },
13
15
  "keywords": [
14
16
  "react-native",
@@ -24,9 +26,10 @@
24
26
  },
25
27
  "devDependencies": {
26
28
  "@shopify/react-native-skia": "^2.4.14",
27
- "@types/matter-js": "^0.20.2"
28
- },
29
- "dependencies": {
30
- "matter-js": "^0.20.0"
29
+ "@types/matter-js": "^0.20.2",
30
+ "@types/node": "^25.0.9",
31
+ "jsdom": "^27.4.0",
32
+ "typescript": "^5.0.0",
33
+ "vitest": "^4.0.17"
31
34
  }
32
35
  }
@@ -1,32 +1,42 @@
1
- import React, { useRef, useEffect, useState, useImperativeHandle, forwardRef } from 'react';
2
- import { View, StyleSheet, TouchableWithoutFeedback, LayoutChangeEvent, Platform } from 'react-native';
1
+ import React, { useRef, useEffect, useState, forwardRef, useImperativeHandle } from 'react';
2
+ import { View, StyleSheet, LayoutChangeEvent, ViewStyle, PanResponder, PanResponderInstance, GestureResponderEvent, Image } from 'react-native';
3
3
  import { Canvas, Skia, Picture, SkPicture } from '@shopify/react-native-skia';
4
4
  import { ExodeUIEngine } from './engine';
5
- import { Artboard, Fit, Alignment } from './types';
5
+ import { Artboard, Fit, Alignment, ComponentEvent } from './types';
6
6
 
7
7
  export interface ExodeUIViewProps {
8
8
  artboard?: Artboard;
9
- style?: any;
9
+ src?: string;
10
+ style?: ViewStyle;
10
11
  autoPlay?: boolean;
11
12
  fit?: Fit;
12
13
  alignment?: Alignment;
13
- onReady?: (engine: ExodeUIEngine) => void;
14
+ onReady?: (engine: any) => void; // Changed type to any as per instruction
14
15
  onTrigger?: (triggerName: string, animationName: string) => void;
15
16
  onInputUpdate?: (nameOrId: string, value: any) => void;
17
+ onComponentChange?: (event: ComponentEvent) => void; // Added
18
+ onToggle?: (name: string, checked: boolean) => void; // Changed value to checked
19
+ onInputChange?: (name: string, text: string) => void; // Changed value to text
20
+ onInputFocus?: (name: string) => void;
21
+ onInputBlur?: (name: string) => void;
16
22
  }
17
23
 
18
24
  export const ExodeUIView = forwardRef<any, ExodeUIViewProps>(
19
- ({ artboard, style, autoPlay = true, fit = 'Contain', alignment = 'Center', onReady, onTrigger, onInputUpdate }, ref) => {
25
+ ({ artboard, src, style, autoPlay = true, fit = 'Contain', alignment = 'Center', onReady, onTrigger, onInputUpdate, onInputFocus, onInputBlur, onToggle, onInputChange, onComponentChange }, ref) => {
20
26
 
21
27
  const engineRef = useRef<ExodeUIEngine>(new ExodeUIEngine());
22
28
  const [picture, setPicture] = useState<SkPicture | null>(null);
23
29
  const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
24
30
  const lastTimeRef = useRef<number>(0);
25
31
  const rafRef = useRef<number | undefined>(undefined);
32
+ const dimsRef = useRef(dimensions);
33
+
34
+ useEffect(() => {
35
+ dimsRef.current = dimensions;
36
+ }, [dimensions]);
26
37
 
27
38
  useImperativeHandle(ref, () => ({
28
39
  getEngine: () => engineRef.current,
29
- // Expose other methods if needed
30
40
  }));
31
41
 
32
42
  useEffect(() => {
@@ -34,26 +44,78 @@ export const ExodeUIView = forwardRef<any, ExodeUIViewProps>(
34
44
  }, [fit, alignment]);
35
45
 
36
46
  useEffect(() => {
37
- if (onTrigger) {
38
- engineRef.current.setTriggerCallback(onTrigger);
39
- }
47
+ if (onTrigger) engineRef.current.setTriggerCallback(onTrigger);
40
48
  }, [onTrigger]);
41
49
 
42
50
  useEffect(() => {
43
- if (onInputUpdate) {
44
- engineRef.current.setInputUpdateCallback(onInputUpdate);
45
- }
51
+ if (onInputUpdate) engineRef.current.setInputUpdateCallback(onInputUpdate);
46
52
  }, [onInputUpdate]);
47
53
 
48
54
  useEffect(() => {
49
- if (artboard) {
50
- engineRef.current.load(artboard);
51
- if (onReady) onReady(engineRef.current);
52
- }
53
- }, [artboard]);
55
+ if (onInputFocus) engineRef.current.setInputFocusCallback(onInputFocus);
56
+ }, [onInputFocus]);
57
+
58
+ useEffect(() => {
59
+ if (onInputBlur) engineRef.current.setInputBlurCallback(onInputBlur);
60
+ }, [onInputBlur]);
61
+
62
+ // New useEffect hooks for additional callbacks
63
+ useEffect(() => {
64
+ if (onToggle) engineRef.current.setToggleCallback(onToggle);
65
+ }, [onToggle]);
66
+
67
+ useEffect(() => {
68
+ if (onInputChange) engineRef.current.setInputChangeCallback(onInputChange);
69
+ }, [onInputChange]);
70
+
71
+ useEffect(() => {
72
+ if (onComponentChange) engineRef.current.setComponentChangeCallback(onComponentChange);
73
+ }, [onComponentChange]);
74
+
75
+ // Load Artboard
76
+ useEffect(() => {
77
+ const loadArtboard = async () => {
78
+ let data = artboard;
79
+
80
+ // Handle numeric asset IDs (from require)
81
+ if (typeof data === 'number') {
82
+ const source = Image.resolveAssetSource(data);
83
+ if (source && source.uri) {
84
+ try {
85
+ const response = await fetch(source.uri);
86
+ data = await response.json() as Artboard;
87
+ } catch (e) {
88
+ console.error('[ExodeUIView] Failed to fetch artboard from asset ID:', data, e);
89
+ return;
90
+ }
91
+ }
92
+ }
93
+
94
+ if (!data && src) {
95
+ try {
96
+ const response = await fetch(src);
97
+ data = await response.json() as Artboard;
98
+ } catch (e) {
99
+ console.error('[ExodeUIView] Failed to fetch artboard from src:', src, e);
100
+ return;
101
+ }
102
+ }
103
+
104
+ if (data) {
105
+ console.log('[ExodeUIView] ✅ Successfully loaded artboard:', data.name || 'Untitled', 'Objects:', data.objects?.length);
106
+ engineRef.current.load(data);
107
+ if (onReady) onReady(engineRef.current);
108
+ } else {
109
+ console.warn('[ExodeUIView] ❌ No artboard data found to load');
110
+ }
111
+ };
112
+
113
+ loadArtboard();
114
+ }, [artboard, src, onReady]); // Added onReady to dependencies
54
115
 
55
116
  const onLayout = (event: LayoutChangeEvent) => {
56
117
  const { width, height } = event.nativeEvent.layout;
118
+ console.log(`[ExodeUIView] Layout: ${width}x${height}`);
57
119
  setDimensions({ width, height });
58
120
  };
59
121
 
@@ -61,6 +123,11 @@ export const ExodeUIView = forwardRef<any, ExodeUIViewProps>(
61
123
  const loop = (timestamp: number) => {
62
124
  if (lastTimeRef.current === 0) lastTimeRef.current = timestamp;
63
125
  const dt = (timestamp - lastTimeRef.current) / 1000;
126
+ if (dt > 1) { // Cap dt to avoid large jumps if tab backgrounded
127
+ lastTimeRef.current = timestamp;
128
+ rafRef.current = requestAnimationFrame(loop);
129
+ return;
130
+ }
64
131
  lastTimeRef.current = timestamp;
65
132
 
66
133
  const engine = engineRef.current;
@@ -87,22 +154,49 @@ export const ExodeUIView = forwardRef<any, ExodeUIViewProps>(
87
154
  return () => {
88
155
  if (rafRef.current) cancelAnimationFrame(rafRef.current);
89
156
  };
90
- }, [autoPlay, dimensions]); // depend on dimensions to restart loop/recording? Actually loop uses ref values ideally but dimensions state update triggers re-render anyway.
91
-
92
- const handleTouch = (e: any) => {
93
- const { locationX, locationY } = e.nativeEvent;
94
- engineRef.current.handlePointerInput('click', locationX, locationY, dimensions.width, dimensions.height);
95
- };
157
+ }, [autoPlay, dimensions]);
158
+
159
+ const panResponder = useRef(
160
+ PanResponder.create({
161
+ onStartShouldSetPanResponder: () => true,
162
+ onStartShouldSetPanResponderCapture: () => true,
163
+ onMoveShouldSetPanResponder: () => true,
164
+ onMoveShouldSetPanResponderCapture: () => true,
165
+ onPanResponderGrant: (e) => {
166
+ const { locationX, locationY } = e.nativeEvent;
167
+ console.log(`[ExodeUIView] PanResponder Grant: ${locationX.toFixed(1)}, ${locationY.toFixed(1)}`);
168
+ engineRef.current.handlePointerInput('PointerDown', locationX, locationY, dimsRef.current.width, dimsRef.current.height);
169
+ },
170
+ onPanResponderMove: (e) => {
171
+ const { locationX, locationY } = e.nativeEvent;
172
+ engineRef.current.handlePointerInput('PointerMove', locationX, locationY, dimsRef.current.width, dimsRef.current.height);
173
+ },
174
+ onPanResponderRelease: (e) => {
175
+ const { locationX, locationY } = e.nativeEvent;
176
+ console.log(`[ExodeUIView] PanResponder Release: ${locationX.toFixed(1)}, ${locationY.toFixed(1)}`);
177
+ engineRef.current.handlePointerInput('click', locationX, locationY, dimsRef.current.width, dimsRef.current.height);
178
+ engineRef.current.handlePointerInput('PointerUp', locationX, locationY, dimsRef.current.width, dimsRef.current.height);
179
+ },
180
+ onPanResponderTerminate: (e) => {
181
+ const { locationX, locationY } = e.nativeEvent;
182
+ engineRef.current.handlePointerInput('PointerUp', locationX, locationY, dimsRef.current.width, dimsRef.current.height);
183
+ },
184
+ onShouldBlockNativeResponder: () => true,
185
+ onPanResponderTerminationRequest: () => false,
186
+ })
187
+ ).current;
96
188
 
97
189
  return (
98
- <View style={[styles.container, style]} onLayout={onLayout}>
99
- <TouchableWithoutFeedback onPress={handleTouch}>
100
- <View style={StyleSheet.absoluteFill}>
101
- <Canvas style={{ flex: 1 }}>
102
- {picture && <Picture picture={picture} />}
103
- </Canvas>
104
- </View>
105
- </TouchableWithoutFeedback>
190
+ <View
191
+ style={[styles.container, style]}
192
+ onLayout={onLayout}
193
+ {...panResponder.panHandlers}
194
+ >
195
+ <View style={StyleSheet.absoluteFill} pointerEvents="none">
196
+ <Canvas style={{ flex: 1 }}>
197
+ {picture && <Picture picture={picture} />}
198
+ </Canvas>
199
+ </View>
106
200
  </View>
107
201
  );
108
202
  }
@@ -111,5 +205,6 @@ export const ExodeUIView = forwardRef<any, ExodeUIViewProps>(
111
205
  const styles = StyleSheet.create({
112
206
  container: {
113
207
  overflow: 'hidden',
208
+ backgroundColor: '#0F0F1A', // Match playground background
114
209
  }
115
210
  });
@@ -0,0 +1,299 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ import { ExodeUIEngine } from '../engine';
3
+ import { Artboard, ConditionOp } from '../types';
4
+
5
+ // Mock Skia since it requires native bindings
6
+ vi.mock('@shopify/react-native-skia', () => ({
7
+ Skia: {
8
+ Color: vi.fn((color: string) => color),
9
+ Path: {
10
+ Make: vi.fn(() => ({
11
+ addRect: vi.fn(),
12
+ addRRect: vi.fn(),
13
+ addOval: vi.fn(),
14
+ moveTo: vi.fn(),
15
+ lineTo: vi.fn(),
16
+ close: vi.fn(),
17
+ })),
18
+ },
19
+ RRectXY: vi.fn((rect, rx, ry) => ({ rect, rx, ry })),
20
+ Paint: vi.fn(() => ({
21
+ setStyle: vi.fn(),
22
+ setColor: vi.fn(),
23
+ setAntiAlias: vi.fn(),
24
+ })),
25
+ },
26
+ PaintStyle: {
27
+ Fill: 'fill',
28
+ Stroke: 'stroke',
29
+ },
30
+ }));
31
+
32
+ // Mock Matter.js
33
+ vi.mock('matter-js', () => ({
34
+ default: {
35
+ Engine: {
36
+ create: vi.fn(() => ({
37
+ gravity: { x: 0, y: 1 },
38
+ world: {},
39
+ })),
40
+ update: vi.fn(),
41
+ },
42
+ Bodies: {
43
+ rectangle: vi.fn((x, y, w, h, options) => ({
44
+ position: { x, y },
45
+ angle: options?.angle || 0,
46
+ isStatic: options?.isStatic || false,
47
+ })),
48
+ circle: vi.fn((x, y, r, options) => ({
49
+ position: { x, y },
50
+ angle: options?.angle || 0,
51
+ isStatic: options?.isStatic || false,
52
+ })),
53
+ },
54
+ World: {
55
+ add: vi.fn(),
56
+ },
57
+ Body: {
58
+ setPosition: vi.fn(),
59
+ setAngle: vi.fn(),
60
+ },
61
+ },
62
+ }));
63
+
64
+ const mockArtboard: Artboard = {
65
+ name: 'Test Artboard',
66
+ width: 1000,
67
+ height: 1000,
68
+ backgroundColor: '#ffffff',
69
+ objects: [
70
+ {
71
+ type: 'Shape',
72
+ id: 'obj1',
73
+ name: 'Rectangle',
74
+ transform: { x: 0, y: 0, rotation: 0, scale_x: 1, scale_y: 1 },
75
+ geometry: { type: 'Rectangle', width: 100, height: 100 },
76
+ style: { fill: { type: 'Solid', color: '#ff0000', opacity: 1 } }
77
+ }
78
+ ],
79
+ animations: [
80
+ {
81
+ id: 'anim1',
82
+ name: 'Rotate',
83
+ duration: 1,
84
+ tracks: [
85
+ {
86
+ object_id: 'obj1',
87
+ property: 'rotation',
88
+ keyframes: [
89
+ { time: 0, value: 0, easing: 'Linear' },
90
+ { time: 1, value: 360, easing: 'Linear' }
91
+ ]
92
+ }
93
+ ]
94
+ }
95
+ ],
96
+ stateMachine: {
97
+ inputs: [
98
+ { id: 'in1', name: 'isRotating', value: { type: 'Boolean', value: false } }
99
+ ],
100
+ layers: [
101
+ {
102
+ name: 'Base Layer',
103
+ entryStateId: 'state1',
104
+ states: [
105
+ {
106
+ id: 'state1',
107
+ name: 'Idle',
108
+ x: 0, y: 0,
109
+ transitions: [
110
+ {
111
+ id: 't1',
112
+ targetStateId: 'state2',
113
+ duration: 0,
114
+ conditions: [
115
+ { inputId: 'in1', op: ConditionOp.IsTrue }
116
+ ]
117
+ }
118
+ ]
119
+ },
120
+ {
121
+ id: 'state2',
122
+ name: 'Rotating',
123
+ x: 100, y: 0,
124
+ animationId: 'anim1',
125
+ transitions: [
126
+ {
127
+ id: 't2',
128
+ targetStateId: 'state1',
129
+ duration: 0,
130
+ conditions: [
131
+ { inputId: 'in1', op: ConditionOp.IsFalse }
132
+ ]
133
+ }
134
+ ]
135
+ }
136
+ ]
137
+ }
138
+ ]
139
+ }
140
+ };
141
+
142
+ describe('ExodeUIEngine (React Native)', () => {
143
+ let engine: ExodeUIEngine;
144
+
145
+ beforeEach(() => {
146
+ engine = new ExodeUIEngine();
147
+ engine.load(mockArtboard);
148
+ });
149
+
150
+ it('should initialize with the entry state', () => {
151
+ expect(engine.getActiveStateIds()).toContain('state1');
152
+ });
153
+
154
+ it('should transition to state2 when input is set to true', () => {
155
+ engine.setInputBool('isRotating', true);
156
+ expect(engine.getActiveStateIds()).toContain('state2');
157
+ });
158
+
159
+ it('should transition back to state1 when input is set to false', () => {
160
+ engine.setInputBool('isRotating', true);
161
+ expect(engine.getActiveStateIds()).toContain('state2');
162
+
163
+ engine.setInputBool('isRotating', false);
164
+ expect(engine.getActiveStateIds()).toContain('state1');
165
+ });
166
+
167
+ it('should apply animation correctly after time advancement', () => {
168
+ engine.setInputBool('isRotating', true); // Enter rotating state
169
+
170
+ // Advance time by 0.5s (half of duration)
171
+ engine.advance(0.5);
172
+
173
+ const state = engine.getObjectState('obj1');
174
+ // Rotation goes from 0 to 360 over 1s, so at 0.5s it should be 180
175
+ expect(state.rotation).toBe(180);
176
+
177
+ // Advance to the end
178
+ engine.advance(0.5);
179
+ expect(engine.getObjectState('obj1').rotation).toBe(360);
180
+ });
181
+
182
+ it('should handle numeric inputs and conditions', () => {
183
+ const artboardWithNumber: Artboard = {
184
+ ...mockArtboard,
185
+ stateMachine: {
186
+ inputs: [
187
+ { id: 'num1', name: 'powerLevel', value: { type: 'Number', value: 0 } }
188
+ ],
189
+ layers: [
190
+ {
191
+ name: 'Base Layer',
192
+ entryStateId: 'state1',
193
+ states: [
194
+ {
195
+ id: 'state1',
196
+ name: 'Low',
197
+ x: 0, y: 0,
198
+ transitions: [
199
+ {
200
+ id: 't1',
201
+ targetStateId: 'state2',
202
+ duration: 0,
203
+ conditions: [
204
+ { inputId: 'num1', op: ConditionOp.GreaterThan, value: 50 }
205
+ ]
206
+ }
207
+ ]
208
+ },
209
+ {
210
+ id: 'state2',
211
+ name: 'High',
212
+ x: 100, y: 0,
213
+ transitions: []
214
+ }
215
+ ]
216
+ }
217
+ ]
218
+ }
219
+ };
220
+
221
+ engine.load(artboardWithNumber);
222
+ expect(engine.getActiveStateIds()).toContain('state1');
223
+
224
+ engine.setInputNumber('powerLevel', 100);
225
+ expect(engine.getActiveStateIds()).toContain('state2');
226
+ });
227
+
228
+ it('should handle color interpolation', () => {
229
+ const artboardWithColor: Artboard = {
230
+ ...mockArtboard,
231
+ animations: [
232
+ {
233
+ id: 'colorAnim',
234
+ name: 'ColorChange',
235
+ duration: 1,
236
+ tracks: [
237
+ {
238
+ object_id: 'obj1',
239
+ property: 'style.fill.color',
240
+ keyframes: [
241
+ { time: 0, value: '#ff0000', easing: 'Linear' },
242
+ { time: 1, value: '#0000ff', easing: 'Linear' }
243
+ ]
244
+ }
245
+ ]
246
+ }
247
+ ],
248
+ stateMachine: {
249
+ inputs: [
250
+ { id: 'in1', name: 'animate', value: { type: 'Boolean', value: false } }
251
+ ],
252
+ layers: [
253
+ {
254
+ name: 'Base Layer',
255
+ entryStateId: 'state1',
256
+ states: [
257
+ {
258
+ id: 'state1',
259
+ name: 'Idle',
260
+ x: 0, y: 0,
261
+ transitions: [
262
+ {
263
+ id: 't1',
264
+ targetStateId: 'state2',
265
+ duration: 0,
266
+ conditions: [
267
+ { inputId: 'in1', op: ConditionOp.IsTrue }
268
+ ]
269
+ }
270
+ ]
271
+ },
272
+ {
273
+ id: 'state2',
274
+ name: 'Animating',
275
+ x: 100, y: 0,
276
+ animationId: 'colorAnim',
277
+ transitions: []
278
+ }
279
+ ]
280
+ }
281
+ ]
282
+ }
283
+ };
284
+
285
+ engine.load(artboardWithColor);
286
+ engine.setInputBool('animate', true);
287
+
288
+ // At t=0, color should be red
289
+ const initialState = engine.getObjectState('obj1');
290
+ expect(initialState.style.fill.color).toBe('#ff0000');
291
+
292
+ // Advance to 0.5s (halfway)
293
+ engine.advance(0.5);
294
+ const halfwayState = engine.getObjectState('obj1');
295
+ // #ff0000 (255,0,0) -> #0000ff (0,0,255)
296
+ // Halfway is (128, 0, 128) -> #800080
297
+ expect(halfwayState.style.fill.color).toBe('#800080');
298
+ });
299
+ });