react-native-color-picker-palette 1.0.0
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/LICENSE +21 -0
- package/README.md +340 -0
- package/package.json +70 -0
- package/src/core/hooks/index.ts +2 -0
- package/src/core/hooks/useColor.ts +93 -0
- package/src/core/hooks/useComponentLayout.ts +38 -0
- package/src/core/index.ts +19 -0
- package/src/core/services/ColorService.ts +338 -0
- package/src/core/services/index.ts +1 -0
- package/src/core/types/index.ts +211 -0
- package/src/core/utils/clamp.ts +16 -0
- package/src/core/utils/format.ts +59 -0
- package/src/core/utils/index.ts +8 -0
- package/src/full/components/Alpha.tsx +221 -0
- package/src/full/components/ColorPicker.tsx +206 -0
- package/src/full/components/Fields/HexField.tsx +125 -0
- package/src/full/components/Fields/RgbFields.tsx +192 -0
- package/src/full/components/Fields/index.tsx +70 -0
- package/src/full/components/Hue.tsx +188 -0
- package/src/full/components/RectangleSaturation.tsx +203 -0
- package/src/full/components/Saturation.tsx +258 -0
- package/src/full/components/Thumb.tsx +47 -0
- package/src/full/components/Value.tsx +192 -0
- package/src/full/components/index.ts +8 -0
- package/src/full/index.ts +69 -0
- package/src/index.ts +19 -0
- package/src/lite/components/Alpha.tsx +228 -0
- package/src/lite/components/ColorPicker.tsx +209 -0
- package/src/lite/components/Fields/HexField.tsx +103 -0
- package/src/lite/components/Fields/RgbFields.tsx +138 -0
- package/src/lite/components/Fields/index.tsx +53 -0
- package/src/lite/components/Hue.tsx +192 -0
- package/src/lite/components/RectangleSaturation.tsx +238 -0
- package/src/lite/components/Saturation.tsx +289 -0
- package/src/lite/components/Thumb.tsx +47 -0
- package/src/lite/components/Value.tsx +201 -0
- package/src/lite/components/index.ts +8 -0
- package/src/lite/index.ts +75 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import React, { memo, useState, useCallback, useEffect } from 'react';
|
|
2
|
+
import { View, TextInput, Text, StyleSheet } from 'react-native';
|
|
3
|
+
import type { IFieldsProps } from '../../../core/types';
|
|
4
|
+
import { ColorService } from '../../../core/services';
|
|
5
|
+
import { clamp, safeParseInt } from '../../../core/utils';
|
|
6
|
+
|
|
7
|
+
interface RgbInputProps {
|
|
8
|
+
label: string;
|
|
9
|
+
value: number;
|
|
10
|
+
onChange: (value: number) => void;
|
|
11
|
+
onComplete: () => void;
|
|
12
|
+
disabled?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Single RGB value input
|
|
17
|
+
*/
|
|
18
|
+
const RgbInput = memo(
|
|
19
|
+
({ label, value, onChange, onComplete, disabled }: RgbInputProps) => {
|
|
20
|
+
const [inputValue, setInputValue] = useState(String(value));
|
|
21
|
+
const [isFocused, setIsFocused] = useState(false);
|
|
22
|
+
|
|
23
|
+
// Update input when value changes externally
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (!isFocused) {
|
|
26
|
+
setInputValue(String(value));
|
|
27
|
+
}
|
|
28
|
+
}, [value, isFocused]);
|
|
29
|
+
|
|
30
|
+
const handleChangeText = useCallback((text: string) => {
|
|
31
|
+
// Only allow numbers
|
|
32
|
+
const numericValue = text.replace(/[^0-9]/g, '');
|
|
33
|
+
setInputValue(numericValue);
|
|
34
|
+
}, []);
|
|
35
|
+
|
|
36
|
+
const handleBlur = useCallback(() => {
|
|
37
|
+
setIsFocused(false);
|
|
38
|
+
const numValue = clamp(safeParseInt(inputValue), 0, 255);
|
|
39
|
+
setInputValue(String(numValue));
|
|
40
|
+
onChange(numValue);
|
|
41
|
+
onComplete();
|
|
42
|
+
}, [inputValue, onChange, onComplete]);
|
|
43
|
+
|
|
44
|
+
const handleFocus = useCallback(() => {
|
|
45
|
+
setIsFocused(true);
|
|
46
|
+
}, []);
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<View style={styles.inputContainer}>
|
|
50
|
+
<Text style={styles.label}>{label}</Text>
|
|
51
|
+
<TextInput
|
|
52
|
+
style={[
|
|
53
|
+
styles.input,
|
|
54
|
+
disabled && styles.inputDisabled,
|
|
55
|
+
]}
|
|
56
|
+
value={inputValue}
|
|
57
|
+
onChangeText={handleChangeText}
|
|
58
|
+
onBlur={handleBlur}
|
|
59
|
+
onFocus={handleFocus}
|
|
60
|
+
editable={!disabled}
|
|
61
|
+
keyboardType="number-pad"
|
|
62
|
+
maxLength={3}
|
|
63
|
+
placeholder="0"
|
|
64
|
+
placeholderTextColor="#999999"
|
|
65
|
+
/>
|
|
66
|
+
</View>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* RGB color input fields
|
|
73
|
+
*
|
|
74
|
+
* Features:
|
|
75
|
+
* - Three inputs for R, G, B values (0-255)
|
|
76
|
+
* - Validates input on blur
|
|
77
|
+
* - Only allows numeric input
|
|
78
|
+
*
|
|
79
|
+
* @example
|
|
80
|
+
* ```tsx
|
|
81
|
+
* <RgbFields
|
|
82
|
+
* color={color}
|
|
83
|
+
* onChange={handleChange}
|
|
84
|
+
* onChangeComplete={handleChangeComplete}
|
|
85
|
+
* />
|
|
86
|
+
* ```
|
|
87
|
+
*/
|
|
88
|
+
export const RgbFields = memo(
|
|
89
|
+
({
|
|
90
|
+
color,
|
|
91
|
+
onChange,
|
|
92
|
+
onChangeComplete,
|
|
93
|
+
disabled = false,
|
|
94
|
+
}: IFieldsProps) => {
|
|
95
|
+
const handleRChange = useCallback(
|
|
96
|
+
(r: number) => {
|
|
97
|
+
const nextColor = ColorService.convert('rgb', {
|
|
98
|
+
...color.rgb,
|
|
99
|
+
r,
|
|
100
|
+
});
|
|
101
|
+
onChange(nextColor);
|
|
102
|
+
},
|
|
103
|
+
[color.rgb, onChange]
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
const handleGChange = useCallback(
|
|
107
|
+
(g: number) => {
|
|
108
|
+
const nextColor = ColorService.convert('rgb', {
|
|
109
|
+
...color.rgb,
|
|
110
|
+
g,
|
|
111
|
+
});
|
|
112
|
+
onChange(nextColor);
|
|
113
|
+
},
|
|
114
|
+
[color.rgb, onChange]
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
const handleBChange = useCallback(
|
|
118
|
+
(b: number) => {
|
|
119
|
+
const nextColor = ColorService.convert('rgb', {
|
|
120
|
+
...color.rgb,
|
|
121
|
+
b,
|
|
122
|
+
});
|
|
123
|
+
onChange(nextColor);
|
|
124
|
+
},
|
|
125
|
+
[color.rgb, onChange]
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
const handleComplete = useCallback(() => {
|
|
129
|
+
onChangeComplete?.(color);
|
|
130
|
+
}, [color, onChangeComplete]);
|
|
131
|
+
|
|
132
|
+
return (
|
|
133
|
+
<View style={styles.container}>
|
|
134
|
+
<RgbInput
|
|
135
|
+
label="R"
|
|
136
|
+
value={color.rgb.r}
|
|
137
|
+
onChange={handleRChange}
|
|
138
|
+
onComplete={handleComplete}
|
|
139
|
+
disabled={disabled}
|
|
140
|
+
/>
|
|
141
|
+
<RgbInput
|
|
142
|
+
label="G"
|
|
143
|
+
value={color.rgb.g}
|
|
144
|
+
onChange={handleGChange}
|
|
145
|
+
onComplete={handleComplete}
|
|
146
|
+
disabled={disabled}
|
|
147
|
+
/>
|
|
148
|
+
<RgbInput
|
|
149
|
+
label="B"
|
|
150
|
+
value={color.rgb.b}
|
|
151
|
+
onChange={handleBChange}
|
|
152
|
+
onComplete={handleComplete}
|
|
153
|
+
disabled={disabled}
|
|
154
|
+
/>
|
|
155
|
+
</View>
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
const styles = StyleSheet.create({
|
|
161
|
+
container: {
|
|
162
|
+
flexDirection: 'row',
|
|
163
|
+
gap: 8,
|
|
164
|
+
},
|
|
165
|
+
inputContainer: {
|
|
166
|
+
flex: 1,
|
|
167
|
+
},
|
|
168
|
+
label: {
|
|
169
|
+
fontSize: 10,
|
|
170
|
+
fontWeight: '600',
|
|
171
|
+
color: '#666666',
|
|
172
|
+
marginBottom: 4,
|
|
173
|
+
textAlign: 'center',
|
|
174
|
+
},
|
|
175
|
+
input: {
|
|
176
|
+
height: 36,
|
|
177
|
+
borderWidth: 1,
|
|
178
|
+
borderColor: '#E0E0E0',
|
|
179
|
+
borderRadius: 6,
|
|
180
|
+
paddingHorizontal: 8,
|
|
181
|
+
fontSize: 14,
|
|
182
|
+
color: '#333333',
|
|
183
|
+
backgroundColor: '#FFFFFF',
|
|
184
|
+
textAlign: 'center',
|
|
185
|
+
},
|
|
186
|
+
inputDisabled: {
|
|
187
|
+
backgroundColor: '#F5F5F5',
|
|
188
|
+
color: '#999999',
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
RgbFields.displayName = 'RgbFields';
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import React, { memo } from 'react';
|
|
2
|
+
import { View, StyleSheet } from 'react-native';
|
|
3
|
+
import type { IFieldsProps } from '../../../core/types';
|
|
4
|
+
import { HexField } from './HexField';
|
|
5
|
+
import { RgbFields } from './RgbFields';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Color input fields container
|
|
9
|
+
*
|
|
10
|
+
* Includes:
|
|
11
|
+
* - HEX field for hex color input
|
|
12
|
+
* - RGB fields for R, G, B value inputs
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```tsx
|
|
16
|
+
* <Fields
|
|
17
|
+
* color={color}
|
|
18
|
+
* onChange={handleChange}
|
|
19
|
+
* onChangeComplete={handleChangeComplete}
|
|
20
|
+
* />
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
export const Fields = memo(
|
|
24
|
+
({
|
|
25
|
+
color,
|
|
26
|
+
onChange,
|
|
27
|
+
onChangeComplete,
|
|
28
|
+
disabled = false,
|
|
29
|
+
}: IFieldsProps) => {
|
|
30
|
+
return (
|
|
31
|
+
<View style={styles.container}>
|
|
32
|
+
<View style={styles.hexContainer}>
|
|
33
|
+
<HexField
|
|
34
|
+
color={color}
|
|
35
|
+
onChange={onChange}
|
|
36
|
+
onChangeComplete={onChangeComplete}
|
|
37
|
+
disabled={disabled}
|
|
38
|
+
/>
|
|
39
|
+
</View>
|
|
40
|
+
<View style={styles.rgbContainer}>
|
|
41
|
+
<RgbFields
|
|
42
|
+
color={color}
|
|
43
|
+
onChange={onChange}
|
|
44
|
+
onChangeComplete={onChangeComplete}
|
|
45
|
+
disabled={disabled}
|
|
46
|
+
/>
|
|
47
|
+
</View>
|
|
48
|
+
</View>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
const styles = StyleSheet.create({
|
|
54
|
+
container: {
|
|
55
|
+
flexDirection: 'row',
|
|
56
|
+
gap: 12,
|
|
57
|
+
alignItems: 'flex-end',
|
|
58
|
+
},
|
|
59
|
+
hexContainer: {
|
|
60
|
+
flex: 1,
|
|
61
|
+
},
|
|
62
|
+
rgbContainer: {
|
|
63
|
+
flex: 2,
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
Fields.displayName = 'Fields';
|
|
68
|
+
|
|
69
|
+
export { HexField } from './HexField';
|
|
70
|
+
export { RgbFields } from './RgbFields';
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import React, { memo, useCallback, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
View,
|
|
4
|
+
StyleSheet,
|
|
5
|
+
PanResponder,
|
|
6
|
+
GestureResponderEvent,
|
|
7
|
+
LayoutChangeEvent,
|
|
8
|
+
} from 'react-native';
|
|
9
|
+
import Svg, { Defs, LinearGradient, Stop, Rect } from 'react-native-svg';
|
|
10
|
+
import type { IHueProps } from '../../core/types';
|
|
11
|
+
import { ColorService } from '../../core/services';
|
|
12
|
+
import { clamp } from '../../core/utils';
|
|
13
|
+
import { Thumb } from './Thumb';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Hue slider bar component
|
|
17
|
+
*
|
|
18
|
+
* Features:
|
|
19
|
+
* - Uses react-native-svg for smooth rainbow gradient
|
|
20
|
+
* - Caches measurements for better performance
|
|
21
|
+
* - Horizontal rainbow gradient bar
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* ```tsx
|
|
25
|
+
* <Hue
|
|
26
|
+
* color={color}
|
|
27
|
+
* onChange={handleChange}
|
|
28
|
+
* barHeight={10}
|
|
29
|
+
* thumbSize={24}
|
|
30
|
+
* />
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
export const Hue = memo(
|
|
34
|
+
({
|
|
35
|
+
color,
|
|
36
|
+
onChange,
|
|
37
|
+
onChangeComplete,
|
|
38
|
+
barHeight,
|
|
39
|
+
thumbSize,
|
|
40
|
+
disabled = false,
|
|
41
|
+
}: IHueProps) => {
|
|
42
|
+
const containerRef = useRef<View>(null);
|
|
43
|
+
const [width, setWidth] = useState(1);
|
|
44
|
+
const measurementCache = useRef<number | null>(null);
|
|
45
|
+
|
|
46
|
+
// Calculate thumb position from hue value
|
|
47
|
+
const thumbPosition = useMemo(() => {
|
|
48
|
+
return (color.hsv.h / 360) * width;
|
|
49
|
+
}, [color.hsv.h, width]);
|
|
50
|
+
|
|
51
|
+
// Update measurement cache
|
|
52
|
+
const updateMeasurement = useCallback(() => {
|
|
53
|
+
return new Promise<number>((resolve) => {
|
|
54
|
+
if (measurementCache.current !== null) {
|
|
55
|
+
resolve(measurementCache.current);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
containerRef.current?.measureInWindow((x) => {
|
|
59
|
+
measurementCache.current = x;
|
|
60
|
+
resolve(x);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
}, []);
|
|
64
|
+
|
|
65
|
+
// Convert touch position to hue
|
|
66
|
+
// Also sets saturation to 100% since the bar represents the edge of the wheel
|
|
67
|
+
const updateColorFromPosition = useCallback(
|
|
68
|
+
async (pageX: number, isFinal: boolean) => {
|
|
69
|
+
const wx = await updateMeasurement();
|
|
70
|
+
const x = clamp(pageX - wx, 0, width);
|
|
71
|
+
const hue = (x / width) * 360;
|
|
72
|
+
|
|
73
|
+
const nextColor = ColorService.convert('hsv', {
|
|
74
|
+
...color.hsv,
|
|
75
|
+
h: clamp(hue, 0, 360),
|
|
76
|
+
s: 100, // Move to edge of wheel (full saturation)
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
onChange(nextColor);
|
|
80
|
+
|
|
81
|
+
if (isFinal) {
|
|
82
|
+
onChangeComplete?.(nextColor);
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
[color.hsv, width, onChange, onChangeComplete, updateMeasurement]
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
// PanResponder for touch handling
|
|
89
|
+
const panResponder = useMemo(
|
|
90
|
+
() =>
|
|
91
|
+
PanResponder.create({
|
|
92
|
+
onStartShouldSetPanResponder: () => !disabled,
|
|
93
|
+
onMoveShouldSetPanResponder: () => !disabled,
|
|
94
|
+
onPanResponderGrant: (evt: GestureResponderEvent) => {
|
|
95
|
+
measurementCache.current = null;
|
|
96
|
+
updateColorFromPosition(evt.nativeEvent.pageX, false);
|
|
97
|
+
},
|
|
98
|
+
onPanResponderMove: (evt: GestureResponderEvent) => {
|
|
99
|
+
updateColorFromPosition(evt.nativeEvent.pageX, false);
|
|
100
|
+
},
|
|
101
|
+
onPanResponderRelease: (evt: GestureResponderEvent) => {
|
|
102
|
+
updateColorFromPosition(evt.nativeEvent.pageX, true);
|
|
103
|
+
},
|
|
104
|
+
}),
|
|
105
|
+
[disabled, updateColorFromPosition]
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
// Handle layout to get width and clear cache
|
|
109
|
+
const handleLayout = useCallback((event: LayoutChangeEvent) => {
|
|
110
|
+
setWidth(event.nativeEvent.layout.width);
|
|
111
|
+
measurementCache.current = null;
|
|
112
|
+
}, []);
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
<View
|
|
116
|
+
ref={containerRef}
|
|
117
|
+
style={[
|
|
118
|
+
styles.container,
|
|
119
|
+
{
|
|
120
|
+
height: barHeight + thumbSize,
|
|
121
|
+
opacity: disabled ? 0.5 : 1,
|
|
122
|
+
},
|
|
123
|
+
]}
|
|
124
|
+
onLayout={handleLayout}
|
|
125
|
+
{...panResponder.panHandlers}
|
|
126
|
+
>
|
|
127
|
+
{/* Gradient bar using SVG */}
|
|
128
|
+
<View style={[styles.barContainer, { height: barHeight, top: thumbSize / 2 }]}>
|
|
129
|
+
<Svg width="100%" height={barHeight}>
|
|
130
|
+
<Defs>
|
|
131
|
+
<LinearGradient id="hueGradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
|
132
|
+
<Stop offset="0%" stopColor="#FF0000" />
|
|
133
|
+
<Stop offset="16.67%" stopColor="#FFFF00" />
|
|
134
|
+
<Stop offset="33.33%" stopColor="#00FF00" />
|
|
135
|
+
<Stop offset="50%" stopColor="#00FFFF" />
|
|
136
|
+
<Stop offset="66.67%" stopColor="#0000FF" />
|
|
137
|
+
<Stop offset="83.33%" stopColor="#FF00FF" />
|
|
138
|
+
<Stop offset="100%" stopColor="#FF0000" />
|
|
139
|
+
</LinearGradient>
|
|
140
|
+
</Defs>
|
|
141
|
+
<Rect
|
|
142
|
+
x="0"
|
|
143
|
+
y="0"
|
|
144
|
+
width="100%"
|
|
145
|
+
height={barHeight}
|
|
146
|
+
rx={barHeight / 2}
|
|
147
|
+
ry={barHeight / 2}
|
|
148
|
+
fill="url(#hueGradient)"
|
|
149
|
+
/>
|
|
150
|
+
</Svg>
|
|
151
|
+
</View>
|
|
152
|
+
|
|
153
|
+
{/* Thumb indicator - centered vertically with bar */}
|
|
154
|
+
<View
|
|
155
|
+
style={[
|
|
156
|
+
styles.thumbContainer,
|
|
157
|
+
{
|
|
158
|
+
left: thumbPosition - thumbSize / 2,
|
|
159
|
+
top: barHeight / 2,
|
|
160
|
+
},
|
|
161
|
+
]}
|
|
162
|
+
pointerEvents="none"
|
|
163
|
+
>
|
|
164
|
+
<Thumb size={thumbSize} color={`hsl(${color.hsv.h}, 100%, 50%)`} />
|
|
165
|
+
</View>
|
|
166
|
+
</View>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
const styles = StyleSheet.create({
|
|
172
|
+
container: {
|
|
173
|
+
position: 'relative',
|
|
174
|
+
width: '100%',
|
|
175
|
+
},
|
|
176
|
+
barContainer: {
|
|
177
|
+
position: 'absolute',
|
|
178
|
+
left: 0,
|
|
179
|
+
right: 0,
|
|
180
|
+
overflow: 'hidden',
|
|
181
|
+
},
|
|
182
|
+
thumbContainer: {
|
|
183
|
+
position: 'absolute',
|
|
184
|
+
zIndex: 10,
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
Hue.displayName = 'Hue';
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import React, { memo, useCallback, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
View,
|
|
4
|
+
StyleSheet,
|
|
5
|
+
PanResponder,
|
|
6
|
+
GestureResponderEvent,
|
|
7
|
+
LayoutChangeEvent,
|
|
8
|
+
} from 'react-native';
|
|
9
|
+
import Svg, { Defs, LinearGradient, Stop, Rect } from 'react-native-svg';
|
|
10
|
+
import type { IRectangleSaturationProps } from '../../core/types';
|
|
11
|
+
import { ColorService } from '../../core/services';
|
|
12
|
+
import { clamp } from '../../core/utils';
|
|
13
|
+
import { Thumb } from './Thumb';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Rectangle Saturation/Value Picker - SVG Version
|
|
17
|
+
*
|
|
18
|
+
* Uses react-native-svg for smooth gradients.
|
|
19
|
+
*
|
|
20
|
+
* Layout:
|
|
21
|
+
* - Top-right: Current hue at full saturation (100% S, 100% V)
|
|
22
|
+
* - Top-left: White (0% S, 100% V)
|
|
23
|
+
* - Bottom-left: Black (0% S, 0% V)
|
|
24
|
+
* - Bottom-right: Black (100% S, 0% V)
|
|
25
|
+
*
|
|
26
|
+
* X-axis: Saturation (0% to 100%)
|
|
27
|
+
* Y-axis: Value/Brightness (100% to 0%)
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* ```tsx
|
|
31
|
+
* <RectangleSaturation
|
|
32
|
+
* color={color}
|
|
33
|
+
* onChange={handleChange}
|
|
34
|
+
* width={250}
|
|
35
|
+
* height={150}
|
|
36
|
+
* thumbSize={24}
|
|
37
|
+
* />
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
export const RectangleSaturation = memo(
|
|
41
|
+
({
|
|
42
|
+
color,
|
|
43
|
+
onChange,
|
|
44
|
+
onChangeComplete,
|
|
45
|
+
width,
|
|
46
|
+
height,
|
|
47
|
+
thumbSize,
|
|
48
|
+
disabled = false,
|
|
49
|
+
}: IRectangleSaturationProps) => {
|
|
50
|
+
const containerRef = useRef<View>(null);
|
|
51
|
+
const measurementCache = useRef<{ x: number; y: number } | null>(null);
|
|
52
|
+
const [isLayoutReady, setIsLayoutReady] = useState(false);
|
|
53
|
+
|
|
54
|
+
// Calculate thumb position from HSV values
|
|
55
|
+
const thumbPosition = useMemo(() => {
|
|
56
|
+
const x = (color.hsv.s / 100) * width - thumbSize / 2;
|
|
57
|
+
const y = ((100 - color.hsv.v) / 100) * height - thumbSize / 2;
|
|
58
|
+
return {
|
|
59
|
+
x: clamp(x, -thumbSize / 2, width - thumbSize / 2),
|
|
60
|
+
y: clamp(y, -thumbSize / 2, height - thumbSize / 2),
|
|
61
|
+
};
|
|
62
|
+
}, [color.hsv.s, color.hsv.v, width, height, thumbSize]);
|
|
63
|
+
|
|
64
|
+
// Update measurement cache
|
|
65
|
+
const updateMeasurement = useCallback(() => {
|
|
66
|
+
return new Promise<{ x: number; y: number }>((resolve) => {
|
|
67
|
+
if (measurementCache.current) {
|
|
68
|
+
resolve(measurementCache.current);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
containerRef.current?.measureInWindow((x, y) => {
|
|
72
|
+
measurementCache.current = { x, y };
|
|
73
|
+
resolve({ x, y });
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
}, []);
|
|
77
|
+
|
|
78
|
+
// Convert touch coordinates to HSV
|
|
79
|
+
const updateColorFromPosition = useCallback(
|
|
80
|
+
async (pageX: number, pageY: number, isFinal: boolean) => {
|
|
81
|
+
const measurement = await updateMeasurement();
|
|
82
|
+
|
|
83
|
+
const x = clamp(pageX - measurement.x, 0, width);
|
|
84
|
+
const y = clamp(pageY - measurement.y, 0, height);
|
|
85
|
+
|
|
86
|
+
const saturation = (x / width) * 100;
|
|
87
|
+
const value = 100 - (y / height) * 100;
|
|
88
|
+
|
|
89
|
+
const nextColor = ColorService.convert('hsv', {
|
|
90
|
+
h: color.hsv.h,
|
|
91
|
+
s: clamp(saturation, 0, 100),
|
|
92
|
+
v: clamp(value, 0, 100),
|
|
93
|
+
a: color.hsv.a,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
onChange(nextColor);
|
|
97
|
+
|
|
98
|
+
if (isFinal) {
|
|
99
|
+
onChangeComplete?.(nextColor);
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
[color.hsv.h, color.hsv.a, width, height, onChange, onChangeComplete, updateMeasurement]
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
// Handle layout
|
|
106
|
+
const handleLayout = useCallback((event: LayoutChangeEvent) => {
|
|
107
|
+
measurementCache.current = null;
|
|
108
|
+
setIsLayoutReady(true);
|
|
109
|
+
}, []);
|
|
110
|
+
|
|
111
|
+
// PanResponder
|
|
112
|
+
const panResponder = useMemo(
|
|
113
|
+
() =>
|
|
114
|
+
PanResponder.create({
|
|
115
|
+
onStartShouldSetPanResponder: () => !disabled,
|
|
116
|
+
onMoveShouldSetPanResponder: () => !disabled,
|
|
117
|
+
onPanResponderGrant: (evt: GestureResponderEvent) => {
|
|
118
|
+
measurementCache.current = null;
|
|
119
|
+
updateColorFromPosition(evt.nativeEvent.pageX, evt.nativeEvent.pageY, false);
|
|
120
|
+
},
|
|
121
|
+
onPanResponderMove: (evt: GestureResponderEvent) => {
|
|
122
|
+
updateColorFromPosition(evt.nativeEvent.pageX, evt.nativeEvent.pageY, false);
|
|
123
|
+
},
|
|
124
|
+
onPanResponderRelease: (evt: GestureResponderEvent) => {
|
|
125
|
+
updateColorFromPosition(evt.nativeEvent.pageX, evt.nativeEvent.pageY, true);
|
|
126
|
+
},
|
|
127
|
+
}),
|
|
128
|
+
[disabled, updateColorFromPosition]
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
// Current hue color at full saturation
|
|
132
|
+
const hueColor = `hsl(${color.hsv.h}, 100%, 50%)`;
|
|
133
|
+
|
|
134
|
+
return (
|
|
135
|
+
<View
|
|
136
|
+
ref={containerRef}
|
|
137
|
+
onLayout={handleLayout}
|
|
138
|
+
style={[
|
|
139
|
+
styles.container,
|
|
140
|
+
{
|
|
141
|
+
width,
|
|
142
|
+
height,
|
|
143
|
+
opacity: disabled ? 0.5 : 1,
|
|
144
|
+
},
|
|
145
|
+
]}
|
|
146
|
+
{...panResponder.panHandlers}
|
|
147
|
+
>
|
|
148
|
+
<Svg width={width} height={height}>
|
|
149
|
+
<Defs>
|
|
150
|
+
{/* White gradient: left (white) to right (transparent) */}
|
|
151
|
+
<LinearGradient id="whiteGradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
|
152
|
+
<Stop offset="0%" stopColor="#FFFFFF" stopOpacity="1" />
|
|
153
|
+
<Stop offset="100%" stopColor="#FFFFFF" stopOpacity="0" />
|
|
154
|
+
</LinearGradient>
|
|
155
|
+
|
|
156
|
+
{/* Black gradient: top (transparent) to bottom (black) */}
|
|
157
|
+
<LinearGradient id="blackGradient" x1="0%" y1="0%" x2="0%" y2="100%">
|
|
158
|
+
<Stop offset="0%" stopColor="#000000" stopOpacity="0" />
|
|
159
|
+
<Stop offset="100%" stopColor="#000000" stopOpacity="1" />
|
|
160
|
+
</LinearGradient>
|
|
161
|
+
</Defs>
|
|
162
|
+
|
|
163
|
+
{/* Base hue color */}
|
|
164
|
+
<Rect x="0" y="0" width={width} height={height} fill={hueColor} rx={8} ry={8} />
|
|
165
|
+
|
|
166
|
+
{/* White gradient overlay */}
|
|
167
|
+
<Rect x="0" y="0" width={width} height={height} fill="url(#whiteGradient)" rx={8} ry={8} />
|
|
168
|
+
|
|
169
|
+
{/* Black gradient overlay */}
|
|
170
|
+
<Rect x="0" y="0" width={width} height={height} fill="url(#blackGradient)" rx={8} ry={8} />
|
|
171
|
+
</Svg>
|
|
172
|
+
|
|
173
|
+
{/* Thumb */}
|
|
174
|
+
<View
|
|
175
|
+
style={[
|
|
176
|
+
styles.thumbContainer,
|
|
177
|
+
{
|
|
178
|
+
left: thumbPosition.x,
|
|
179
|
+
top: thumbPosition.y,
|
|
180
|
+
},
|
|
181
|
+
]}
|
|
182
|
+
pointerEvents="none"
|
|
183
|
+
>
|
|
184
|
+
<Thumb size={thumbSize} color={color.hex} />
|
|
185
|
+
</View>
|
|
186
|
+
</View>
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
const styles = StyleSheet.create({
|
|
192
|
+
container: {
|
|
193
|
+
position: 'relative',
|
|
194
|
+
borderRadius: 8,
|
|
195
|
+
overflow: 'hidden',
|
|
196
|
+
},
|
|
197
|
+
thumbContainer: {
|
|
198
|
+
position: 'absolute',
|
|
199
|
+
zIndex: 10,
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
RectangleSaturation.displayName = 'RectangleSaturation';
|