260 lines
7.6 KiB
TypeScript
260 lines
7.6 KiB
TypeScript
import React, { useState, useEffect, useMemo } from 'react';
|
|
import { View, Text as RNText, StyleSheet, PanResponder, Dimensions } from 'react-native';
|
|
import Svg, { Circle, Rect, Defs, LinearGradient, Stop } from 'react-native-svg';
|
|
import { useSettings } from '@/theme';
|
|
|
|
const DefsElement = Defs as unknown as React.ComponentType<{ children?: React.ReactNode }>;
|
|
|
|
const { width: SCREEN_WIDTH } = Dimensions.get('window');
|
|
const WHEEL_SIZE = Math.min(SCREEN_WIDTH - 64, 280);
|
|
const THUMB_SIZE = 24;
|
|
|
|
function hexToHsv(hex: string) {
|
|
const r = parseInt(hex.slice(1, 3), 16) / 255;
|
|
const g = parseInt(hex.slice(3, 5), 16) / 255;
|
|
const b = parseInt(hex.slice(5, 7), 16) / 255;
|
|
const max = Math.max(r, g, b);
|
|
const min = Math.min(r, g, b);
|
|
const delta = max - min;
|
|
let h = 0;
|
|
if (delta !== 0) {
|
|
if (max === r) h = ((g - b) / delta) % 6;
|
|
else if (max === g) h = (b - r) / delta + 2;
|
|
else h = (r - g) / delta + 4;
|
|
h = Math.round(h * 60);
|
|
if (h < 0) h += 360;
|
|
}
|
|
const s = max === 0 ? 0 : delta / max;
|
|
const v = max;
|
|
return { h, s, v };
|
|
}
|
|
|
|
function hsvToHex(h: number, s: number, v: number) {
|
|
const c = v * s;
|
|
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
|
const m = v - c;
|
|
let r = 0,
|
|
g = 0,
|
|
b = 0;
|
|
if (h < 60) {
|
|
r = c;
|
|
g = x;
|
|
b = 0;
|
|
} else if (h < 120) {
|
|
r = x;
|
|
g = c;
|
|
b = 0;
|
|
} else if (h < 180) {
|
|
r = 0;
|
|
g = c;
|
|
b = x;
|
|
} else if (h < 240) {
|
|
r = 0;
|
|
g = x;
|
|
b = c;
|
|
} else if (h < 300) {
|
|
r = x;
|
|
g = 0;
|
|
b = c;
|
|
} else {
|
|
r = c;
|
|
g = 0;
|
|
b = x;
|
|
}
|
|
const toHex = (n: number) =>
|
|
Math.round((n + m) * 255)
|
|
.toString(16)
|
|
.padStart(2, '0');
|
|
return `#${toHex(r)}${toHex(g)}${toHex(b)}`.toUpperCase();
|
|
}
|
|
|
|
interface ColorWheelProps {
|
|
color: string;
|
|
opacity: number;
|
|
onChange: (color: string, opacity: number) => void;
|
|
}
|
|
|
|
export default function ColorWheel({ color, opacity, onChange }: ColorWheelProps) {
|
|
const { theme } = useSettings();
|
|
const initialHsv = useMemo(() => hexToHsv(color), [color]);
|
|
const [hue, setHue] = useState(initialHsv.h);
|
|
const [saturation, setSaturation] = useState(initialHsv.s);
|
|
const [value, setValue] = useState(initialHsv.v);
|
|
const [prevColor, setPrevColor] = useState(color);
|
|
|
|
if (prevColor !== color) {
|
|
setPrevColor(color);
|
|
setHue(initialHsv.h);
|
|
setSaturation(initialHsv.s);
|
|
setValue(initialHsv.v);
|
|
}
|
|
|
|
useEffect(() => {
|
|
const newColor = hsvToHex(hue, saturation, value);
|
|
onChange(newColor, opacity);
|
|
}, [hue, saturation, value, opacity, onChange]);
|
|
|
|
const updateFromWheel = (locationX: number, locationY: number) => {
|
|
const center = WHEEL_SIZE / 2;
|
|
const dx = locationX - center;
|
|
const dy = locationY - center;
|
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
const radius = (WHEEL_SIZE - THUMB_SIZE) / 2;
|
|
if (distance > radius) return;
|
|
const angle = Math.atan2(dy, dx) * (180 / Math.PI);
|
|
let h = angle + 180;
|
|
if (h >= 360) h -= 360;
|
|
setHue(h);
|
|
setSaturation(distance / radius);
|
|
setValue(1 - distance / radius);
|
|
};
|
|
|
|
const wheelPanResponder = React.useMemo(
|
|
() =>
|
|
PanResponder.create({
|
|
onStartShouldSetPanResponder: () => true,
|
|
onMoveShouldSetPanResponder: () => true,
|
|
onPanResponderGrant: event => {
|
|
updateFromWheel(event.nativeEvent.locationX, event.nativeEvent.locationY);
|
|
},
|
|
onPanResponderMove: event => {
|
|
updateFromWheel(event.nativeEvent.locationX, event.nativeEvent.locationY);
|
|
},
|
|
}),
|
|
[]
|
|
);
|
|
|
|
const setHueFromLocation = (locationX: number) => {
|
|
setHue(Math.min(360, Math.max(0, (locationX / WHEEL_SIZE) * 360)));
|
|
};
|
|
|
|
const huePanResponder = React.useMemo(
|
|
() =>
|
|
PanResponder.create({
|
|
onStartShouldSetPanResponder: () => true,
|
|
onMoveShouldSetPanResponder: () => true,
|
|
onPanResponderGrant: event => {
|
|
setHueFromLocation(event.nativeEvent.locationX);
|
|
},
|
|
onPanResponderMove: event => {
|
|
setHueFromLocation(event.nativeEvent.locationX);
|
|
},
|
|
}),
|
|
[]
|
|
);
|
|
|
|
const thumbX =
|
|
((WHEEL_SIZE - THUMB_SIZE) / 2) * saturation * Math.cos(((hue - 180) * Math.PI) / 180) +
|
|
WHEEL_SIZE / 2 -
|
|
THUMB_SIZE / 2;
|
|
const thumbY =
|
|
((WHEEL_SIZE - THUMB_SIZE) / 2) * saturation * Math.sin(((hue - 180) * Math.PI) / 180) +
|
|
WHEEL_SIZE / 2 -
|
|
THUMB_SIZE / 2;
|
|
const hueThumbX = (hue / 360) * WHEEL_SIZE - THUMB_SIZE / 2;
|
|
|
|
return (
|
|
<View style={styles.container}>
|
|
<View style={styles.wheelContainer}>
|
|
<Svg width={WHEEL_SIZE} height={WHEEL_SIZE}>
|
|
<DefsElement>
|
|
<LinearGradient id="satGradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
|
<Stop offset="0%" stopColor={`hsl(${hue}, 100%, 50%)`} stopOpacity={1} />
|
|
<Stop offset="100%" stopColor="#FFFFFF" stopOpacity={1} />
|
|
</LinearGradient>
|
|
<LinearGradient id="valGradient" x1="0%" y1="0%" x2="0%" y2="100%">
|
|
<Stop offset="0%" stopColor="transparent" stopOpacity={0} />
|
|
<Stop offset="100%" stopColor="#000000" stopOpacity={1} />
|
|
</LinearGradient>
|
|
</DefsElement>
|
|
<Circle
|
|
cx={WHEEL_SIZE / 2}
|
|
cy={WHEEL_SIZE / 2}
|
|
r={(WHEEL_SIZE - THUMB_SIZE) / 2}
|
|
fill="url(#satGradient)"
|
|
/>
|
|
<Circle
|
|
cx={WHEEL_SIZE / 2}
|
|
cy={WHEEL_SIZE / 2}
|
|
r={(WHEEL_SIZE - THUMB_SIZE) / 2}
|
|
fill="url(#valGradient)"
|
|
/>
|
|
<Circle
|
|
cx={thumbX + THUMB_SIZE / 2}
|
|
cy={thumbY + THUMB_SIZE / 2}
|
|
r={THUMB_SIZE / 2}
|
|
fill="#FFFFFF"
|
|
stroke="#000000"
|
|
strokeWidth={2}
|
|
/>
|
|
</Svg>
|
|
<View {...wheelPanResponder.panHandlers} style={StyleSheet.absoluteFill} />
|
|
</View>
|
|
|
|
<View style={styles.hueContainer}>
|
|
<Svg width={WHEEL_SIZE} height={36}>
|
|
<DefsElement>
|
|
<LinearGradient id="hueGradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
|
<Stop offset="0%" stopColor="#FF0000" />
|
|
<Stop offset="17%" stopColor="#FFFF00" />
|
|
<Stop offset="33%" stopColor="#00FF00" />
|
|
<Stop offset="50%" stopColor="#00FFFF" />
|
|
<Stop offset="67%" stopColor="#0000FF" />
|
|
<Stop offset="83%" stopColor="#FF00FF" />
|
|
<Stop offset="100%" stopColor="#FF0000" />
|
|
</LinearGradient>
|
|
</DefsElement>
|
|
<Rect x={0} y={0} width={WHEEL_SIZE} height={36} rx={18} fill="url(#hueGradient)" />
|
|
<Circle
|
|
cx={hueThumbX + THUMB_SIZE / 2}
|
|
cy={18}
|
|
r={THUMB_SIZE / 2}
|
|
fill="#FFFFFF"
|
|
stroke="#000000"
|
|
strokeWidth={2}
|
|
/>
|
|
</Svg>
|
|
<View {...huePanResponder.panHandlers} style={StyleSheet.absoluteFill} />
|
|
</View>
|
|
|
|
<View style={styles.previewContainer}>
|
|
<View style={[styles.preview, { backgroundColor: hsvToHex(hue, saturation, value) }]} />
|
|
<RNText style={[styles.previewText, { color: theme.text }]}>
|
|
{hsvToHex(hue, saturation, value)}
|
|
</RNText>
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
alignItems: 'center',
|
|
gap: 16,
|
|
},
|
|
wheelContainer: {
|
|
position: 'relative',
|
|
},
|
|
hueContainer: {
|
|
position: 'relative',
|
|
width: WHEEL_SIZE,
|
|
},
|
|
previewContainer: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 12,
|
|
},
|
|
preview: {
|
|
width: 44,
|
|
height: 44,
|
|
borderRadius: 12,
|
|
borderWidth: 1,
|
|
borderColor: '#00000020',
|
|
},
|
|
previewText: {
|
|
fontSize: 15,
|
|
fontWeight: '500',
|
|
fontFamily: 'monospace',
|
|
},
|
|
});
|