exodeui-react-native 1.0.0 → 1.0.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.
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.1",
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,22 +1,32 @@
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
5
  import { Artboard, Fit, Alignment } from './types';
6
6
 
7
+ // Assuming ComponentEvent is defined elsewhere or will be defined.
8
+ // For now, let's define a placeholder if not provided.
9
+ type ComponentEvent = any;
10
+
7
11
  export interface ExodeUIViewProps {
8
12
  artboard?: Artboard;
9
- style?: any;
13
+ src?: string;
14
+ style?: ViewStyle;
10
15
  autoPlay?: boolean;
11
16
  fit?: Fit;
12
17
  alignment?: Alignment;
13
- onReady?: (engine: ExodeUIEngine) => void;
18
+ onReady?: (engine: any) => void; // Changed type to any as per instruction
14
19
  onTrigger?: (triggerName: string, animationName: string) => void;
15
20
  onInputUpdate?: (nameOrId: string, value: any) => void;
21
+ onComponentChange?: (event: ComponentEvent) => void; // Added
22
+ onToggle?: (name: string, checked: boolean) => void; // Changed value to checked
23
+ onInputChange?: (name: string, text: string) => void; // Changed value to text
24
+ onInputFocus?: (name: string) => void;
25
+ onInputBlur?: (name: string) => void;
16
26
  }
17
27
 
18
28
  export const ExodeUIView = forwardRef<any, ExodeUIViewProps>(
19
- ({ artboard, style, autoPlay = true, fit = 'Contain', alignment = 'Center', onReady, onTrigger, onInputUpdate }, ref) => {
29
+ ({ artboard, src, style, autoPlay = true, fit = 'Contain', alignment = 'Center', onReady, onTrigger, onInputUpdate, onInputFocus, onInputBlur, onToggle, onInputChange, onComponentChange }, ref) => {
20
30
 
21
31
  const engineRef = useRef<ExodeUIEngine>(new ExodeUIEngine());
22
32
  const [picture, setPicture] = useState<SkPicture | null>(null);
@@ -26,7 +36,6 @@ export const ExodeUIView = forwardRef<any, ExodeUIViewProps>(
26
36
 
27
37
  useImperativeHandle(ref, () => ({
28
38
  getEngine: () => engineRef.current,
29
- // Expose other methods if needed
30
39
  }));
31
40
 
32
41
  useEffect(() => {
@@ -34,26 +43,76 @@ export const ExodeUIView = forwardRef<any, ExodeUIViewProps>(
34
43
  }, [fit, alignment]);
35
44
 
36
45
  useEffect(() => {
37
- if (onTrigger) {
38
- engineRef.current.setTriggerCallback(onTrigger);
39
- }
46
+ if (onTrigger) engineRef.current.setTriggerCallback(onTrigger);
40
47
  }, [onTrigger]);
41
48
 
42
49
  useEffect(() => {
43
- if (onInputUpdate) {
44
- engineRef.current.setInputUpdateCallback(onInputUpdate);
45
- }
50
+ if (onInputUpdate) engineRef.current.setInputUpdateCallback(onInputUpdate);
46
51
  }, [onInputUpdate]);
47
52
 
48
53
  useEffect(() => {
49
- if (artboard) {
50
- engineRef.current.load(artboard);
51
- if (onReady) onReady(engineRef.current);
52
- }
53
- }, [artboard]);
54
+ if (onInputFocus) engineRef.current.setInputFocusCallback(onInputFocus);
55
+ }, [onInputFocus]);
56
+
57
+ useEffect(() => {
58
+ if (onInputBlur) engineRef.current.setInputBlurCallback(onInputBlur);
59
+ }, [onInputBlur]);
60
+
61
+ // New useEffect hooks for additional callbacks
62
+ useEffect(() => {
63
+ if (onToggle) engineRef.current.setToggleCallback(onToggle);
64
+ }, [onToggle]);
65
+
66
+ useEffect(() => {
67
+ if (onInputChange) engineRef.current.setInputChangeCallback(onInputChange);
68
+ }, [onInputChange]);
69
+
70
+ useEffect(() => {
71
+ if (onComponentChange) engineRef.current.setComponentChangeCallback(onComponentChange);
72
+ }, [onComponentChange]);
73
+
74
+ // Load Artboard
75
+ useEffect(() => {
76
+ const loadArtboard = async () => {
77
+ let data = artboard;
78
+
79
+ // Handle numeric asset IDs (from require)
80
+ if (typeof data === 'number') {
81
+ const source = Image.resolveAssetSource(data);
82
+ if (source && source.uri) {
83
+ try {
84
+ const response = await fetch(source.uri);
85
+ data = await response.json() as Artboard;
86
+ } catch (e) {
87
+ console.error('[ExodeUIView] Failed to fetch artboard from asset ID:', data, e);
88
+ return;
89
+ }
90
+ }
91
+ }
92
+
93
+ if (!data && src) {
94
+ try {
95
+ const response = await fetch(src);
96
+ data = await response.json() as Artboard;
97
+ } catch (e) {
98
+ console.error('[ExodeUIView] Failed to fetch artboard from src:', src, e);
99
+ return;
100
+ }
101
+ }
102
+
103
+ if (data) {
104
+ console.log('[ExodeUIView] Loading artboard:', data.name || 'Untitled');
105
+ engineRef.current.load(data);
106
+ if (onReady) onReady(engineRef.current);
107
+ }
108
+ };
109
+
110
+ loadArtboard();
111
+ }, [artboard, src, onReady]); // Added onReady to dependencies
54
112
 
55
113
  const onLayout = (event: LayoutChangeEvent) => {
56
114
  const { width, height } = event.nativeEvent.layout;
115
+ console.log(`[ExodeUIView] Layout: ${width}x${height}`);
57
116
  setDimensions({ width, height });
58
117
  };
59
118
 
@@ -61,6 +120,11 @@ export const ExodeUIView = forwardRef<any, ExodeUIViewProps>(
61
120
  const loop = (timestamp: number) => {
62
121
  if (lastTimeRef.current === 0) lastTimeRef.current = timestamp;
63
122
  const dt = (timestamp - lastTimeRef.current) / 1000;
123
+ if (dt > 1) { // Cap dt to avoid large jumps if tab backgrounded
124
+ lastTimeRef.current = timestamp;
125
+ rafRef.current = requestAnimationFrame(loop);
126
+ return;
127
+ }
64
128
  lastTimeRef.current = timestamp;
65
129
 
66
130
  const engine = engineRef.current;
@@ -87,22 +151,40 @@ export const ExodeUIView = forwardRef<any, ExodeUIViewProps>(
87
151
  return () => {
88
152
  if (rafRef.current) cancelAnimationFrame(rafRef.current);
89
153
  };
90
- }, [autoPlay, dimensions]); // depend on dimensions to restart loop/recording? Actually loop uses ref values ideally but dimensions state update triggers re-render anyway.
154
+ }, [autoPlay, dimensions]);
91
155
 
92
- const handleTouch = (e: any) => {
156
+ const handleResponderStart = (e: any) => {
157
+ const { locationX, locationY } = e.nativeEvent;
158
+ engineRef.current.handlePointerInput('PointerDown', locationX, locationY, dimensions.width, dimensions.height);
159
+ return true;
160
+ };
161
+
162
+ const handleResponderRelease = (e: any) => {
93
163
  const { locationX, locationY } = e.nativeEvent;
94
164
  engineRef.current.handlePointerInput('click', locationX, locationY, dimensions.width, dimensions.height);
165
+ engineRef.current.handlePointerInput('PointerUp', locationX, locationY, dimensions.width, dimensions.height);
166
+ };
167
+
168
+ const handleResponderMove = (e: any) => {
169
+ const { locationX, locationY } = e.nativeEvent;
170
+ engineRef.current.handlePointerInput('PointerMove', locationX, locationY, dimensions.width, dimensions.height);
95
171
  };
96
172
 
97
173
  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>
174
+ <View
175
+ style={[styles.container, style]}
176
+ onLayout={onLayout}
177
+ onStartShouldSetResponder={() => true}
178
+ onResponderGrant={handleResponderStart}
179
+ onResponderMove={handleResponderMove}
180
+ onResponderRelease={handleResponderRelease}
181
+ onResponderTerminate={handleResponderRelease}
182
+ >
183
+ <View style={StyleSheet.absoluteFill} pointerEvents="none">
184
+ <Canvas style={{ flex: 1 }}>
185
+ {picture && <Picture picture={picture} />}
186
+ </Canvas>
187
+ </View>
106
188
  </View>
107
189
  );
108
190
  }
@@ -111,5 +193,6 @@ export const ExodeUIView = forwardRef<any, ExodeUIViewProps>(
111
193
  const styles = StyleSheet.create({
112
194
  container: {
113
195
  overflow: 'hidden',
196
+ backgroundColor: '#0F0F1A', // Match playground background
114
197
  }
115
198
  });
@@ -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
+ });