@idealyst/animate 1.2.29
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/package.json +72 -0
- package/src/index.native.ts +66 -0
- package/src/index.ts +64 -0
- package/src/types.ts +208 -0
- package/src/useAnimatedStyle.native.ts +168 -0
- package/src/useAnimatedStyle.ts +165 -0
- package/src/useAnimatedValue.native.ts +169 -0
- package/src/useAnimatedValue.ts +227 -0
- package/src/useGradientBorder.native.tsx +229 -0
- package/src/useGradientBorder.ts +180 -0
- package/src/usePresence.native.ts +157 -0
- package/src/usePresence.ts +151 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useAnimatedStyle - Web implementation
|
|
3
|
+
*
|
|
4
|
+
* Creates an animated style that transitions when properties change.
|
|
5
|
+
* Uses CSS transitions for smooth, performant animations.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useMemo, useRef, useEffect } from 'react';
|
|
9
|
+
import { cssTransition, resolveDuration, resolveEasing } from '@idealyst/theme/animation';
|
|
10
|
+
import type { AnimatableProperties, UseAnimatedStyleOptions, AnimatableStyle } from './types';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Hook that returns an animated style object.
|
|
14
|
+
* When the style properties change, they animate smoothly using CSS transitions.
|
|
15
|
+
*
|
|
16
|
+
* @param style - The target style properties
|
|
17
|
+
* @param options - Animation configuration
|
|
18
|
+
* @returns Animated style object to spread onto a component
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* ```tsx
|
|
22
|
+
* const style = useAnimatedStyle({
|
|
23
|
+
* opacity: isVisible ? 1 : 0,
|
|
24
|
+
* transform: [{ translateY: isVisible ? 0 : 20 }],
|
|
25
|
+
* }, {
|
|
26
|
+
* duration: 'normal',
|
|
27
|
+
* easing: 'easeOut',
|
|
28
|
+
* });
|
|
29
|
+
*
|
|
30
|
+
* return <div style={style}>Content</div>;
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
export function useAnimatedStyle(
|
|
34
|
+
style: AnimatableProperties,
|
|
35
|
+
options: UseAnimatedStyleOptions = {}
|
|
36
|
+
): AnimatableStyle {
|
|
37
|
+
const { duration = 'normal', easing = 'easeOut', delay = 0, web } = options;
|
|
38
|
+
|
|
39
|
+
// Use web-specific options if provided
|
|
40
|
+
const finalDuration = web?.duration ?? duration;
|
|
41
|
+
const finalEasing = web?.easing ?? easing;
|
|
42
|
+
const finalDelay = web?.delay ?? delay;
|
|
43
|
+
|
|
44
|
+
// Track if this is the initial render
|
|
45
|
+
const isInitialRender = useRef(true);
|
|
46
|
+
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
isInitialRender.current = false;
|
|
49
|
+
}, []);
|
|
50
|
+
|
|
51
|
+
// Convert transform array to CSS transform string
|
|
52
|
+
const transformString = useMemo(() => {
|
|
53
|
+
if (!style.transform || !Array.isArray(style.transform)) {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return style.transform
|
|
58
|
+
.map((t) => {
|
|
59
|
+
const [key, value] = Object.entries(t)[0];
|
|
60
|
+
// Handle different transform types
|
|
61
|
+
switch (key) {
|
|
62
|
+
case 'translateX':
|
|
63
|
+
case 'translateY':
|
|
64
|
+
return `${key}(${typeof value === 'number' ? `${value}px` : value})`;
|
|
65
|
+
case 'rotate':
|
|
66
|
+
case 'rotateX':
|
|
67
|
+
case 'rotateY':
|
|
68
|
+
case 'rotateZ':
|
|
69
|
+
case 'skewX':
|
|
70
|
+
case 'skewY':
|
|
71
|
+
return `${key}(${value})`;
|
|
72
|
+
case 'scale':
|
|
73
|
+
case 'scaleX':
|
|
74
|
+
case 'scaleY':
|
|
75
|
+
case 'perspective':
|
|
76
|
+
return `${key}(${value})`;
|
|
77
|
+
default:
|
|
78
|
+
return `${key}(${value})`;
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
.join(' ');
|
|
82
|
+
}, [style.transform]);
|
|
83
|
+
|
|
84
|
+
// Build the transition string
|
|
85
|
+
const transition = useMemo(() => {
|
|
86
|
+
// Use custom transition if provided
|
|
87
|
+
if (web?.transition) {
|
|
88
|
+
return web.transition;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Get all animatable property names from the style
|
|
92
|
+
const properties = Object.keys(style).filter((key) => key !== 'transform');
|
|
93
|
+
if (style.transform) {
|
|
94
|
+
properties.push('transform');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (properties.length === 0) {
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Map RN property names to CSS property names
|
|
102
|
+
const cssProperties = properties.map((prop) => {
|
|
103
|
+
switch (prop) {
|
|
104
|
+
case 'backgroundColor':
|
|
105
|
+
return 'background-color';
|
|
106
|
+
case 'borderColor':
|
|
107
|
+
return 'border-color';
|
|
108
|
+
case 'borderWidth':
|
|
109
|
+
return 'border-width';
|
|
110
|
+
case 'borderRadius':
|
|
111
|
+
return 'border-radius';
|
|
112
|
+
case 'borderTopLeftRadius':
|
|
113
|
+
return 'border-top-left-radius';
|
|
114
|
+
case 'borderTopRightRadius':
|
|
115
|
+
return 'border-top-right-radius';
|
|
116
|
+
case 'borderBottomLeftRadius':
|
|
117
|
+
return 'border-bottom-left-radius';
|
|
118
|
+
case 'borderBottomRightRadius':
|
|
119
|
+
return 'border-bottom-right-radius';
|
|
120
|
+
case 'maxHeight':
|
|
121
|
+
return 'max-height';
|
|
122
|
+
case 'maxWidth':
|
|
123
|
+
return 'max-width';
|
|
124
|
+
case 'minHeight':
|
|
125
|
+
return 'min-height';
|
|
126
|
+
case 'minWidth':
|
|
127
|
+
return 'min-width';
|
|
128
|
+
default:
|
|
129
|
+
return prop;
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const durationMs = resolveDuration(finalDuration);
|
|
134
|
+
const easingCss = resolveEasing(finalEasing);
|
|
135
|
+
const delayStr = finalDelay > 0 ? ` ${finalDelay}ms` : '';
|
|
136
|
+
|
|
137
|
+
return cssProperties.map((prop) => `${prop} ${durationMs}ms ${easingCss}${delayStr}`).join(', ');
|
|
138
|
+
}, [style, finalDuration, finalEasing, finalDelay, web?.transition]);
|
|
139
|
+
|
|
140
|
+
// Build the final style object
|
|
141
|
+
const animatedStyle = useMemo(() => {
|
|
142
|
+
const result: Record<string, any> = {};
|
|
143
|
+
|
|
144
|
+
// Copy all style properties except transform
|
|
145
|
+
Object.entries(style).forEach(([key, value]) => {
|
|
146
|
+
if (key !== 'transform' && value !== undefined) {
|
|
147
|
+
result[key] = value;
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// Add transform string
|
|
152
|
+
if (transformString) {
|
|
153
|
+
result.transform = transformString;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Add transition (skip on initial render to avoid animation on mount)
|
|
157
|
+
if (transition && !isInitialRender.current) {
|
|
158
|
+
result.transition = transition;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return result as AnimatableStyle;
|
|
162
|
+
}, [style, transformString, transition]);
|
|
163
|
+
|
|
164
|
+
return animatedStyle;
|
|
165
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useAnimatedValue - Native implementation
|
|
3
|
+
*
|
|
4
|
+
* Creates an animated numeric value using Reanimated shared values.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { useCallback, useMemo } from 'react';
|
|
8
|
+
import {
|
|
9
|
+
useSharedValue,
|
|
10
|
+
withTiming,
|
|
11
|
+
withSpring,
|
|
12
|
+
withDelay,
|
|
13
|
+
Easing,
|
|
14
|
+
interpolate as reanimatedInterpolate,
|
|
15
|
+
Extrapolation,
|
|
16
|
+
useDerivedValue,
|
|
17
|
+
} from 'react-native-reanimated';
|
|
18
|
+
import { timingConfig, springConfig, isSpringEasing, resolveDuration } from '@idealyst/theme/animation';
|
|
19
|
+
import type { AnimatedValue, AnimationOptions, InterpolationConfig } from './types';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Hook that creates an animated numeric value using Reanimated.
|
|
23
|
+
*
|
|
24
|
+
* @param initialValue - Starting value
|
|
25
|
+
* @returns AnimatedValue object with set, setImmediate, and interpolate methods
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* ```tsx
|
|
29
|
+
* const progress = useAnimatedValue(0);
|
|
30
|
+
*
|
|
31
|
+
* // Animate to new value
|
|
32
|
+
* progress.set(1, { duration: 'slow', easing: 'easeOut' });
|
|
33
|
+
*
|
|
34
|
+
* // Use in animated style
|
|
35
|
+
* const animatedStyle = useAnimatedStyle(() => ({
|
|
36
|
+
* opacity: progress.value,
|
|
37
|
+
* }));
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
export function useAnimatedValue(initialValue: number): AnimatedValue {
|
|
41
|
+
const sharedValue = useSharedValue(initialValue);
|
|
42
|
+
|
|
43
|
+
// Set value with animation
|
|
44
|
+
const set = useCallback(
|
|
45
|
+
(target: number, options: AnimationOptions = {}) => {
|
|
46
|
+
const { duration = 'normal', easing = 'easeOut', delay = 0 } = options;
|
|
47
|
+
|
|
48
|
+
const useSpring = isSpringEasing(easing);
|
|
49
|
+
|
|
50
|
+
if (useSpring) {
|
|
51
|
+
const config = springConfig(easing as any);
|
|
52
|
+
sharedValue.value =
|
|
53
|
+
delay > 0 ? withDelay(delay, withSpring(target, config)) : withSpring(target, config);
|
|
54
|
+
} else {
|
|
55
|
+
const config = timingConfig(duration, easing);
|
|
56
|
+
const timingOptions = {
|
|
57
|
+
duration: config.duration,
|
|
58
|
+
easing: Easing.bezier(config.easing[0], config.easing[1], config.easing[2], config.easing[3]),
|
|
59
|
+
};
|
|
60
|
+
sharedValue.value =
|
|
61
|
+
delay > 0 ? withDelay(delay, withTiming(target, timingOptions)) : withTiming(target, timingOptions);
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
[sharedValue]
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
// Set value immediately without animation
|
|
68
|
+
const setImmediate = useCallback(
|
|
69
|
+
(target: number) => {
|
|
70
|
+
sharedValue.value = target;
|
|
71
|
+
},
|
|
72
|
+
[sharedValue]
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
// Interpolate value to another range
|
|
76
|
+
// Note: For native, this returns a function that should be called within useAnimatedStyle
|
|
77
|
+
const interpolate = useCallback(
|
|
78
|
+
<T extends string | number>(config: InterpolationConfig<T>): T => {
|
|
79
|
+
const { inputRange, outputRange, extrapolate = 'extend' } = config;
|
|
80
|
+
const extrapolateLeft = config.extrapolateLeft ?? extrapolate;
|
|
81
|
+
const extrapolateRight = config.extrapolateRight ?? extrapolate;
|
|
82
|
+
|
|
83
|
+
// Map extrapolation to Reanimated's Extrapolation enum
|
|
84
|
+
const getExtrapolation = (type: string) => {
|
|
85
|
+
switch (type) {
|
|
86
|
+
case 'clamp':
|
|
87
|
+
return Extrapolation.CLAMP;
|
|
88
|
+
case 'identity':
|
|
89
|
+
return Extrapolation.IDENTITY;
|
|
90
|
+
default:
|
|
91
|
+
return Extrapolation.EXTEND;
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// For numbers, use Reanimated's interpolate
|
|
96
|
+
if (typeof outputRange[0] === 'number') {
|
|
97
|
+
return reanimatedInterpolate(
|
|
98
|
+
sharedValue.value,
|
|
99
|
+
inputRange,
|
|
100
|
+
outputRange as number[],
|
|
101
|
+
{
|
|
102
|
+
extrapolateLeft: getExtrapolation(extrapolateLeft),
|
|
103
|
+
extrapolateRight: getExtrapolation(extrapolateRight),
|
|
104
|
+
}
|
|
105
|
+
) as T;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// For colors (strings), do manual interpolation
|
|
109
|
+
// Note: In real usage, you'd want to use interpolateColor from Reanimated
|
|
110
|
+
const value = sharedValue.value;
|
|
111
|
+
|
|
112
|
+
// Find the segment
|
|
113
|
+
let i = 0;
|
|
114
|
+
for (; i < inputRange.length - 1; i++) {
|
|
115
|
+
if (value < inputRange[i + 1]) break;
|
|
116
|
+
}
|
|
117
|
+
i = Math.min(i, inputRange.length - 2);
|
|
118
|
+
|
|
119
|
+
const inputMin = inputRange[i];
|
|
120
|
+
const inputMax = inputRange[i + 1];
|
|
121
|
+
const outputMin = outputRange[i] as string;
|
|
122
|
+
const outputMax = outputRange[i + 1] as string;
|
|
123
|
+
|
|
124
|
+
let ratio = (value - inputMin) / (inputMax - inputMin);
|
|
125
|
+
|
|
126
|
+
// Handle extrapolation for colors
|
|
127
|
+
if (value < inputRange[0] && extrapolateLeft === 'clamp') {
|
|
128
|
+
return outputRange[0];
|
|
129
|
+
} else if (value > inputRange[inputRange.length - 1] && extrapolateRight === 'clamp') {
|
|
130
|
+
return outputRange[outputRange.length - 1];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
ratio = Math.max(0, Math.min(1, ratio));
|
|
134
|
+
|
|
135
|
+
// Simple hex color interpolation
|
|
136
|
+
if (outputMin.startsWith('#') && outputMax.startsWith('#')) {
|
|
137
|
+
const parseHex = (hex: string) => {
|
|
138
|
+
const h = hex.replace('#', '');
|
|
139
|
+
return {
|
|
140
|
+
r: parseInt(h.substring(0, 2), 16),
|
|
141
|
+
g: parseInt(h.substring(2, 4), 16),
|
|
142
|
+
b: parseInt(h.substring(4, 6), 16),
|
|
143
|
+
};
|
|
144
|
+
};
|
|
145
|
+
const min = parseHex(outputMin);
|
|
146
|
+
const max = parseHex(outputMax);
|
|
147
|
+
const r = Math.round(min.r + (max.r - min.r) * ratio);
|
|
148
|
+
const g = Math.round(min.g + (max.g - min.g) * ratio);
|
|
149
|
+
const b = Math.round(min.b + (max.b - min.b) * ratio);
|
|
150
|
+
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}` as T;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return (ratio < 0.5 ? outputMin : outputMax) as T;
|
|
154
|
+
},
|
|
155
|
+
[sharedValue]
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
return useMemo(
|
|
159
|
+
() => ({
|
|
160
|
+
get value() {
|
|
161
|
+
return sharedValue.value;
|
|
162
|
+
},
|
|
163
|
+
set,
|
|
164
|
+
setImmediate,
|
|
165
|
+
interpolate,
|
|
166
|
+
}),
|
|
167
|
+
[sharedValue, set, setImmediate, interpolate]
|
|
168
|
+
);
|
|
169
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useAnimatedValue - Web implementation
|
|
3
|
+
*
|
|
4
|
+
* Creates an animated numeric value that can be interpolated.
|
|
5
|
+
* Uses requestAnimationFrame for smooth animations on web.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useState, useRef, useCallback, useMemo } from 'react';
|
|
9
|
+
import { resolveDuration, resolveEasing } from '@idealyst/theme/animation';
|
|
10
|
+
import type { AnimatedValue, AnimationOptions, InterpolationConfig } from './types';
|
|
11
|
+
|
|
12
|
+
// Bezier curve evaluation for custom easing
|
|
13
|
+
function bezierEval(t: number, p1: number, p2: number, p3: number, p4: number): number {
|
|
14
|
+
const cx = 3 * p1;
|
|
15
|
+
const bx = 3 * (p3 - p1) - cx;
|
|
16
|
+
const ax = 1 - cx - bx;
|
|
17
|
+
const cy = 3 * p2;
|
|
18
|
+
const by = 3 * (p4 - p2) - cy;
|
|
19
|
+
const ay = 1 - cy - by;
|
|
20
|
+
|
|
21
|
+
function sampleCurveX(t: number) {
|
|
22
|
+
return ((ax * t + bx) * t + cx) * t;
|
|
23
|
+
}
|
|
24
|
+
function sampleCurveY(t: number) {
|
|
25
|
+
return ((ay * t + by) * t + cy) * t;
|
|
26
|
+
}
|
|
27
|
+
function sampleCurveDerivativeX(t: number) {
|
|
28
|
+
return (3 * ax * t + 2 * bx) * t + cx;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Newton-Raphson iteration to find t for x
|
|
32
|
+
let t2 = t;
|
|
33
|
+
for (let i = 0; i < 8; i++) {
|
|
34
|
+
const x2 = sampleCurveX(t2) - t;
|
|
35
|
+
if (Math.abs(x2) < 1e-6) break;
|
|
36
|
+
const d2 = sampleCurveDerivativeX(t2);
|
|
37
|
+
if (Math.abs(d2) < 1e-6) break;
|
|
38
|
+
t2 = t2 - x2 / d2;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return sampleCurveY(t2);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Parse CSS easing to bezier values
|
|
45
|
+
function getEasingBezier(easing: string): [number, number, number, number] {
|
|
46
|
+
const match = easing.match(/cubic-bezier\(([^)]+)\)/);
|
|
47
|
+
if (match) {
|
|
48
|
+
const values = match[1].split(',').map((v) => parseFloat(v.trim()));
|
|
49
|
+
return values as [number, number, number, number];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Standard easings
|
|
53
|
+
switch (easing) {
|
|
54
|
+
case 'linear':
|
|
55
|
+
return [0, 0, 1, 1];
|
|
56
|
+
case 'ease':
|
|
57
|
+
return [0.25, 0.1, 0.25, 1];
|
|
58
|
+
case 'ease-in':
|
|
59
|
+
return [0.42, 0, 1, 1];
|
|
60
|
+
case 'ease-out':
|
|
61
|
+
return [0, 0, 0.58, 1];
|
|
62
|
+
case 'ease-in-out':
|
|
63
|
+
return [0.42, 0, 0.58, 1];
|
|
64
|
+
default:
|
|
65
|
+
return [0.25, 0.1, 0.25, 1]; // default to ease
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Hook that creates an animated numeric value.
|
|
71
|
+
*
|
|
72
|
+
* @param initialValue - Starting value
|
|
73
|
+
* @returns AnimatedValue object with set, setImmediate, and interpolate methods
|
|
74
|
+
*
|
|
75
|
+
* @example
|
|
76
|
+
* ```tsx
|
|
77
|
+
* const progress = useAnimatedValue(0);
|
|
78
|
+
*
|
|
79
|
+
* // Animate to new value
|
|
80
|
+
* progress.set(1, { duration: 'slow', easing: 'easeOut' });
|
|
81
|
+
*
|
|
82
|
+
* // Interpolate to color
|
|
83
|
+
* const color = progress.interpolate({
|
|
84
|
+
* inputRange: [0, 1],
|
|
85
|
+
* outputRange: ['#fff', '#000'],
|
|
86
|
+
* });
|
|
87
|
+
* ```
|
|
88
|
+
*/
|
|
89
|
+
export function useAnimatedValue(initialValue: number): AnimatedValue {
|
|
90
|
+
const [value, setValue] = useState(initialValue);
|
|
91
|
+
const animationRef = useRef<number | null>(null);
|
|
92
|
+
const currentValueRef = useRef(initialValue);
|
|
93
|
+
|
|
94
|
+
// Cancel any running animation
|
|
95
|
+
const cancelAnimation = useCallback(() => {
|
|
96
|
+
if (animationRef.current !== null) {
|
|
97
|
+
cancelAnimationFrame(animationRef.current);
|
|
98
|
+
animationRef.current = null;
|
|
99
|
+
}
|
|
100
|
+
}, []);
|
|
101
|
+
|
|
102
|
+
// Set value with animation
|
|
103
|
+
const set = useCallback(
|
|
104
|
+
(target: number, options: AnimationOptions = {}) => {
|
|
105
|
+
const { duration = 'normal', easing = 'easeOut', delay = 0 } = options;
|
|
106
|
+
const durationMs = resolveDuration(duration);
|
|
107
|
+
const easingCss = resolveEasing(easing);
|
|
108
|
+
const bezier = getEasingBezier(easingCss);
|
|
109
|
+
|
|
110
|
+
cancelAnimation();
|
|
111
|
+
|
|
112
|
+
const startValue = currentValueRef.current;
|
|
113
|
+
const startTime = performance.now() + delay;
|
|
114
|
+
|
|
115
|
+
const animate = (currentTime: number) => {
|
|
116
|
+
if (currentTime < startTime) {
|
|
117
|
+
animationRef.current = requestAnimationFrame(animate);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const elapsed = currentTime - startTime;
|
|
122
|
+
const progress = Math.min(elapsed / durationMs, 1);
|
|
123
|
+
const easedProgress = bezierEval(progress, bezier[0], bezier[1], bezier[2], bezier[3]);
|
|
124
|
+
const newValue = startValue + (target - startValue) * easedProgress;
|
|
125
|
+
|
|
126
|
+
currentValueRef.current = newValue;
|
|
127
|
+
setValue(newValue);
|
|
128
|
+
|
|
129
|
+
if (progress < 1) {
|
|
130
|
+
animationRef.current = requestAnimationFrame(animate);
|
|
131
|
+
} else {
|
|
132
|
+
animationRef.current = null;
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
animationRef.current = requestAnimationFrame(animate);
|
|
137
|
+
},
|
|
138
|
+
[cancelAnimation]
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
// Set value immediately without animation
|
|
142
|
+
const setImmediate = useCallback(
|
|
143
|
+
(target: number) => {
|
|
144
|
+
cancelAnimation();
|
|
145
|
+
currentValueRef.current = target;
|
|
146
|
+
setValue(target);
|
|
147
|
+
},
|
|
148
|
+
[cancelAnimation]
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
// Interpolate value to another range
|
|
152
|
+
const interpolate = useCallback(
|
|
153
|
+
<T extends string | number>(config: InterpolationConfig<T>): T => {
|
|
154
|
+
const { inputRange, outputRange, extrapolate = 'extend' } = config;
|
|
155
|
+
const extrapolateLeft = config.extrapolateLeft ?? extrapolate;
|
|
156
|
+
const extrapolateRight = config.extrapolateRight ?? extrapolate;
|
|
157
|
+
|
|
158
|
+
// Find the segment
|
|
159
|
+
let i = 0;
|
|
160
|
+
for (; i < inputRange.length - 1; i++) {
|
|
161
|
+
if (value < inputRange[i + 1]) break;
|
|
162
|
+
}
|
|
163
|
+
i = Math.min(i, inputRange.length - 2);
|
|
164
|
+
|
|
165
|
+
const inputMin = inputRange[i];
|
|
166
|
+
const inputMax = inputRange[i + 1];
|
|
167
|
+
const outputMin = outputRange[i];
|
|
168
|
+
const outputMax = outputRange[i + 1];
|
|
169
|
+
|
|
170
|
+
let ratio = (value - inputMin) / (inputMax - inputMin);
|
|
171
|
+
|
|
172
|
+
// Handle extrapolation
|
|
173
|
+
if (value < inputRange[0]) {
|
|
174
|
+
if (extrapolateLeft === 'clamp') {
|
|
175
|
+
return outputRange[0];
|
|
176
|
+
} else if (extrapolateLeft === 'identity') {
|
|
177
|
+
return value as T;
|
|
178
|
+
}
|
|
179
|
+
} else if (value > inputRange[inputRange.length - 1]) {
|
|
180
|
+
if (extrapolateRight === 'clamp') {
|
|
181
|
+
return outputRange[outputRange.length - 1];
|
|
182
|
+
} else if (extrapolateRight === 'identity') {
|
|
183
|
+
return value as T;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Handle color interpolation
|
|
188
|
+
if (typeof outputMin === 'string' && typeof outputMax === 'string') {
|
|
189
|
+
// Simple hex color interpolation
|
|
190
|
+
if (outputMin.startsWith('#') && outputMax.startsWith('#')) {
|
|
191
|
+
const parseHex = (hex: string) => {
|
|
192
|
+
const h = hex.replace('#', '');
|
|
193
|
+
return {
|
|
194
|
+
r: parseInt(h.substring(0, 2), 16),
|
|
195
|
+
g: parseInt(h.substring(2, 4), 16),
|
|
196
|
+
b: parseInt(h.substring(4, 6), 16),
|
|
197
|
+
};
|
|
198
|
+
};
|
|
199
|
+
const min = parseHex(outputMin);
|
|
200
|
+
const max = parseHex(outputMax);
|
|
201
|
+
const r = Math.round(min.r + (max.r - min.r) * ratio);
|
|
202
|
+
const g = Math.round(min.g + (max.g - min.g) * ratio);
|
|
203
|
+
const b = Math.round(min.b + (max.b - min.b) * ratio);
|
|
204
|
+
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}` as T;
|
|
205
|
+
}
|
|
206
|
+
// For other strings, just return based on threshold
|
|
207
|
+
return ratio < 0.5 ? outputMin : outputMax;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Numeric interpolation
|
|
211
|
+
return ((outputMin as number) + ((outputMax as number) - (outputMin as number)) * ratio) as T;
|
|
212
|
+
},
|
|
213
|
+
[value]
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
return useMemo(
|
|
217
|
+
() => ({
|
|
218
|
+
get value() {
|
|
219
|
+
return currentValueRef.current;
|
|
220
|
+
},
|
|
221
|
+
set,
|
|
222
|
+
setImmediate,
|
|
223
|
+
interpolate,
|
|
224
|
+
}),
|
|
225
|
+
[set, setImmediate, interpolate]
|
|
226
|
+
);
|
|
227
|
+
}
|