mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-02-13 00:10:27 +00:00
Replace react-color with custom HSL ColorPicker
Remove the react-color dependency and add a custom ColorPicker implementation. The new component uses HSL parsing and hex<->HSL conversion, provides SatLightPanel and HueSlider subcomponents with mouse/touch drag support, and integrates @heroui/input for inline HEX/HSL editing. The ColorPicker onChange now emits an "hsl(...)" string; theme config parsing was updated to convert that string into the existing "h s% l%" format. Also update package.json to drop react-color.
This commit is contained in:
@@ -73,7 +73,6 @@
|
||||
"qrcode.react": "^4.2.0",
|
||||
"quill": "^2.0.3",
|
||||
"react": "^19.0.0",
|
||||
"react-color": "^2.19.3",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-dropzone": "^14.3.5",
|
||||
"react-error-boundary": "^5.0.0",
|
||||
|
||||
@@ -1,37 +1,409 @@
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover';
|
||||
import React from 'react';
|
||||
import { ColorResult, SketchPicker } from 'react-color';
|
||||
|
||||
// 假定 heroui 提供的 Popover组件
|
||||
import { Input } from "@heroui/input";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@heroui/popover";
|
||||
import React, { useCallback, useEffect, useRef, useState, memo } from "react";
|
||||
|
||||
interface ColorPickerProps {
|
||||
color: string;
|
||||
onChange: (color: ColorResult) => void;
|
||||
onChange: (color: string) => void;
|
||||
}
|
||||
|
||||
// 转换 HSL 字符串到对象
|
||||
const parseHsl = (hslStr: string) => {
|
||||
const match = hslStr.match(/hsl\((\d+(?:\.\d+)?),\s*(\d+(?:\.\d+)?)%,\s*(\d+(?:\.\d+)?)%\)/);
|
||||
if (match) {
|
||||
return { h: parseFloat(match[1]), s: parseFloat(match[2]), l: parseFloat(match[3]) };
|
||||
}
|
||||
return { h: 0, s: 0, l: 0 };
|
||||
};
|
||||
|
||||
// 转换 HEX 到 HSL
|
||||
const hexToHsl = (hex: string) => {
|
||||
let r = 0, g = 0, b = 0;
|
||||
if (hex.length === 4) {
|
||||
r = parseInt("0x" + hex[1] + hex[1]);
|
||||
g = parseInt("0x" + hex[2] + hex[2]);
|
||||
b = parseInt("0x" + hex[3] + hex[3]);
|
||||
} else if (hex.length === 7) {
|
||||
r = parseInt("0x" + hex[1] + hex[2]);
|
||||
g = parseInt("0x" + hex[3] + hex[4]);
|
||||
b = parseInt("0x" + hex[5] + hex[6]);
|
||||
}
|
||||
r /= 255;
|
||||
g /= 255;
|
||||
b /= 255;
|
||||
const cmin = Math.min(r, g, b),
|
||||
cmax = Math.max(r, g, b),
|
||||
delta = cmax - cmin;
|
||||
let h = 0,
|
||||
s = 0,
|
||||
l = 0;
|
||||
|
||||
if (delta === 0) h = 0;
|
||||
else if (cmax === r) h = ((g - b) / delta) % 6;
|
||||
else if (cmax === g) h = (b - r) / delta + 2;
|
||||
else h = (r - g) / delta + 4;
|
||||
|
||||
h = Math.round(h * 60);
|
||||
if (h < 0) h += 360;
|
||||
|
||||
l = (cmax + cmin) / 2;
|
||||
s = delta === 0 ? 0 : delta / (1 - Math.abs(2 * l - 1));
|
||||
s = +(s * 100).toFixed(1);
|
||||
l = +(l * 100).toFixed(1);
|
||||
|
||||
return { h, s, l };
|
||||
};
|
||||
|
||||
// 转换 HSL 到 HEX
|
||||
const hslToHex = (h: number, s: number, l: number) => {
|
||||
s /= 100;
|
||||
l /= 100;
|
||||
const c = (1 - Math.abs(2 * l - 1)) * s;
|
||||
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
||||
const m = l - c / 2;
|
||||
let r = 0, g = 0, b = 0;
|
||||
|
||||
if (0 <= h && h < 60) {
|
||||
r = c; g = x; b = 0;
|
||||
} else if (60 <= h && h < 120) {
|
||||
r = x; g = c; b = 0;
|
||||
} else if (120 <= h && h < 180) {
|
||||
r = 0; g = c; b = x;
|
||||
} else if (180 <= h && h < 240) {
|
||||
r = 0; g = x; b = c;
|
||||
} else if (240 <= h && h < 300) {
|
||||
r = x; g = 0; b = c;
|
||||
} else if (300 <= h && h < 360) {
|
||||
r = c; g = 0; b = x;
|
||||
}
|
||||
r = Math.round((r + m) * 255);
|
||||
g = Math.round((g + m) * 255);
|
||||
b = Math.round((b + m) * 255);
|
||||
|
||||
const toHex = (n: number) => {
|
||||
const hex = n.toString(16);
|
||||
return hex.length === 1 ? "0" + hex : hex;
|
||||
};
|
||||
return "#" + toHex(r) + toHex(g) + toHex(b);
|
||||
};
|
||||
|
||||
interface PanelProps {
|
||||
hsl: { h: number, s: number, l: number; };
|
||||
onChange: (newHsl: { h: number, s: number, l: number; }) => void;
|
||||
}
|
||||
|
||||
// 饱和度/亮度面板
|
||||
const SatLightPanel = memo(({ hsl, onChange }: PanelProps) => {
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
const hslRef = useRef(hsl);
|
||||
useEffect(() => { hslRef.current = hsl; }, [hsl]);
|
||||
|
||||
const updateColor = useCallback((clientX: number, clientY: number) => {
|
||||
if (!panelRef.current) return;
|
||||
const rect = panelRef.current.getBoundingClientRect();
|
||||
const x = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
||||
const y = Math.max(0, Math.min(1, (clientY - rect.top) / rect.height));
|
||||
|
||||
const s_hsv = x;
|
||||
const v_hsv = 1 - y;
|
||||
|
||||
let l_hsl = v_hsv * (1 - s_hsv / 2);
|
||||
let s_hsl = 0;
|
||||
if (l_hsl === 0 || l_hsl === 1) {
|
||||
s_hsl = 0;
|
||||
} else {
|
||||
s_hsl = (v_hsv - l_hsl) / Math.min(l_hsl, 1 - l_hsl);
|
||||
}
|
||||
|
||||
onChange({ h: hslRef.current.h, s: s_hsl * 100, l: l_hsl * 100 });
|
||||
}, [onChange]);
|
||||
|
||||
const handleStart = (clientX: number, clientY: number) => {
|
||||
setIsDragging(true);
|
||||
updateColor(clientX, clientY);
|
||||
};
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
handleStart(e.clientX, e.clientY);
|
||||
};
|
||||
|
||||
const handleTouchStart = (e: React.TouchEvent) => {
|
||||
e.preventDefault();
|
||||
const touch = e.touches[0];
|
||||
handleStart(touch.clientX, touch.clientY);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (isDragging) {
|
||||
e.preventDefault();
|
||||
updateColor(e.clientX, e.clientY);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchMove = (e: TouchEvent) => {
|
||||
if (isDragging) {
|
||||
e.preventDefault();
|
||||
const touch = e.touches[0];
|
||||
updateColor(touch.clientX, touch.clientY);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnd = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
if (isDragging) {
|
||||
window.addEventListener("mousemove", handleMouseMove);
|
||||
window.addEventListener("mouseup", handleEnd);
|
||||
window.addEventListener("touchmove", handleTouchMove, { passive: false });
|
||||
window.addEventListener("touchend", handleEnd);
|
||||
}
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleMouseMove);
|
||||
window.removeEventListener("mouseup", handleEnd);
|
||||
window.removeEventListener("touchmove", handleTouchMove);
|
||||
window.removeEventListener("touchend", handleEnd);
|
||||
};
|
||||
}, [isDragging, updateColor]);
|
||||
|
||||
const l_val = hsl.l / 100;
|
||||
const s_val = hsl.s / 100;
|
||||
const v_hsv = l_val + s_val * Math.min(l_val, 1 - l_val);
|
||||
const s_hsv = v_hsv === 0 ? 0 : 2 * (1 - l_val / v_hsv);
|
||||
|
||||
const markerX = s_hsv * 100;
|
||||
const markerY = (1 - v_hsv) * 100;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="w-full h-40 rounded-lg relative cursor-crosshair overflow-hidden shadow-inner touch-none"
|
||||
style={{
|
||||
backgroundColor: "hsl(" + hsl.h + ", 100%, 50%)",
|
||||
backgroundImage: "linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, transparent)"
|
||||
}}
|
||||
onMouseDown={handleMouseDown}
|
||||
onTouchStart={handleTouchStart}
|
||||
>
|
||||
<div
|
||||
className="w-4 h-4 rounded-full border-2 border-white shadow-md absolute transform -translate-x-1/2 -translate-y-1/2 pointer-events-none"
|
||||
style={{
|
||||
left: markerX + "%",
|
||||
top: markerY + "%",
|
||||
backgroundColor: "hsl(" + hsl.h + ", " + hsl.s + "%, " + hsl.l + "%)"
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
SatLightPanel.displayName = "SatLightPanel";
|
||||
|
||||
const HueSlider = memo(({ hsl, onChange }: PanelProps) => {
|
||||
const sliderRef = useRef<HTMLDivElement>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
const hslRef = useRef(hsl);
|
||||
useEffect(() => { hslRef.current = hsl; }, [hsl]);
|
||||
|
||||
const updateHue = useCallback((clientX: number) => {
|
||||
if (!sliderRef.current) return;
|
||||
const rect = sliderRef.current.getBoundingClientRect();
|
||||
let x = (clientX - rect.left) / rect.width;
|
||||
x = Math.max(0, Math.min(1, x));
|
||||
onChange({ ...hslRef.current, h: x * 360 });
|
||||
}, [onChange]);
|
||||
|
||||
const handleStart = (clientX: number) => {
|
||||
setIsDragging(true);
|
||||
updateHue(clientX);
|
||||
};
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
handleStart(e.clientX);
|
||||
};
|
||||
|
||||
const handleTouchStart = (e: React.TouchEvent) => {
|
||||
e.preventDefault();
|
||||
const touch = e.touches[0];
|
||||
handleStart(touch.clientX);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (isDragging) {
|
||||
e.preventDefault();
|
||||
updateHue(e.clientX);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchMove = (e: TouchEvent) => {
|
||||
if (isDragging) {
|
||||
e.preventDefault();
|
||||
const touch = e.touches[0];
|
||||
updateHue(touch.clientX);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnd = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
if (isDragging) {
|
||||
window.addEventListener("mousemove", handleMouseMove);
|
||||
window.addEventListener("mouseup", handleEnd);
|
||||
window.addEventListener("touchmove", handleTouchMove, { passive: false });
|
||||
window.addEventListener("touchend", handleEnd);
|
||||
}
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleMouseMove);
|
||||
window.removeEventListener("mouseup", handleEnd);
|
||||
window.removeEventListener("touchmove", handleTouchMove);
|
||||
window.removeEventListener("touchend", handleEnd);
|
||||
};
|
||||
}, [isDragging, updateHue]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={sliderRef}
|
||||
className="w-full h-4 rounded-full relative cursor-pointer mt-3 shadow-inner touch-none"
|
||||
style={{
|
||||
background: "linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%)"
|
||||
}}
|
||||
onMouseDown={handleMouseDown}
|
||||
onTouchStart={handleTouchStart}
|
||||
>
|
||||
<div
|
||||
className="w-4 h-4 rounded-full border-2 border-white shadow-md absolute top-0 transform -translate-x-1/2 pointer-events-none bg-white"
|
||||
style={{ left: (hsl.h / 360) * 100 + "%" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
HueSlider.displayName = "HueSlider";
|
||||
|
||||
const ColorPicker: React.FC<ColorPickerProps> = ({ color, onChange }) => {
|
||||
const handleChange = (colorResult: ColorResult) => {
|
||||
onChange(colorResult);
|
||||
const [hsl, setHsl] = useState(parseHsl(color));
|
||||
const [hex, setHex] = useState(hslToHex(hsl.h, hsl.s, hsl.l));
|
||||
const isDraggingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDraggingRef.current) return;
|
||||
const newHsl = parseHsl(color);
|
||||
if (Math.abs(newHsl.h - hsl.h) > 0.1 || Math.abs(newHsl.s - hsl.s) > 0.1 || Math.abs(newHsl.l - hsl.l) > 0.1) {
|
||||
setHsl(newHsl);
|
||||
setHex(hslToHex(newHsl.h, newHsl.s, newHsl.l));
|
||||
}
|
||||
}, [color]);
|
||||
|
||||
const handleHslChange = useCallback((newHsl: { h: number, s: number, l: number; }) => {
|
||||
setHsl(newHsl);
|
||||
setHex(hslToHex(newHsl.h, newHsl.s, newHsl.l));
|
||||
onChange("hsl(" + Math.round(newHsl.h) + ", " + Math.round(newHsl.s) + "%, " + Math.round(newHsl.l) + "%)");
|
||||
}, [onChange]);
|
||||
|
||||
const handleHexChange = (value: string) => {
|
||||
setHex(value);
|
||||
if (/^#[0-9A-Fa-f]{6}$/.test(value)) {
|
||||
const newHsl = hexToHsl(value);
|
||||
handleHslChange(newHsl);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover triggerScaleOnOpen={false}>
|
||||
<Popover triggerScaleOnOpen={false} placement="bottom">
|
||||
<PopoverTrigger>
|
||||
<div
|
||||
className='w-36 h-8 rounded-md cursor-pointer border border-content4'
|
||||
style={{ background: color }}
|
||||
/>
|
||||
<div className="flex items-center gap-2 cursor-pointer group">
|
||||
<div
|
||||
className="w-10 h-10 rounded-lg shadow-sm border-2 border-default-200 transition-transform group-hover:scale-105"
|
||||
style={{ background: color }}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs font-mono text-default-500">{hex}</span>
|
||||
<span className="text-xs font-mono text-default-400">HSL({Math.round(hsl.h)}, {Math.round(hsl.s)}%, {Math.round(hsl.l)}%)</span>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
{/* 移除 PopoverContent 默认的事件阻止,允许鼠标拖动到外部 */}
|
||||
<PopoverContent className='pointer-events-auto'>
|
||||
<SketchPicker
|
||||
color={color}
|
||||
onChange={handleChange}
|
||||
className='!bg-transparent !shadow-none'
|
||||
/>
|
||||
<PopoverContent className="w-72 p-4 bg-background/80 backdrop-blur-xl border border-default-200 shadow-2xl rounded-2xl"
|
||||
onMouseDownCapture={() => { isDraggingRef.current = true; }}
|
||||
onMouseUpCapture={() => { isDraggingRef.current = false; }}
|
||||
onTouchStartCapture={() => { isDraggingRef.current = true; }}
|
||||
onTouchEndCapture={() => { isDraggingRef.current = false; }}
|
||||
>
|
||||
<div className="flex flex-col w-full gap-2">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-sm font-bold text-default-700">选择颜色</span>
|
||||
<div
|
||||
className="w-6 h-6 rounded-full border border-default-200 shadow-sm"
|
||||
style={{ background: color }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SatLightPanel hsl={hsl} onChange={handleHslChange} />
|
||||
<HueSlider hsl={hsl} onChange={handleHslChange} />
|
||||
|
||||
<div className="grid grid-cols-4 gap-2 mt-2 items-center">
|
||||
<span className="text-xs text-default-500 col-span-1">HEX</span>
|
||||
<Input
|
||||
size="sm"
|
||||
variant="flat"
|
||||
value={hex}
|
||||
onChange={(e) => handleHexChange(e.target.value)}
|
||||
className="col-span-3 font-mono"
|
||||
classNames={{
|
||||
input: "text-xs uppercase",
|
||||
inputWrapper: "h-8 min-h-8"
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-2 items-center">
|
||||
<span className="text-xs text-default-500 col-span-1">HSL</span>
|
||||
<div className="col-span-3 flex gap-1">
|
||||
<Input
|
||||
size="sm" variant="flat" type="number"
|
||||
value={Math.round(hsl.h).toString()}
|
||||
onChange={(e) => handleHslChange({ ...hsl, h: Number(e.target.value) })}
|
||||
endContent={<span className="text-xs text-default-400">H</span>}
|
||||
classNames={{ input: "text-xs", inputWrapper: "h-8 min-h-8 px-1" }}
|
||||
/>
|
||||
<Input
|
||||
size="sm" variant="flat" type="number"
|
||||
value={Math.round(hsl.s).toString()}
|
||||
onChange={(e) => handleHslChange({ ...hsl, s: Number(e.target.value) })}
|
||||
endContent={<span className="text-xs text-default-400">S</span>}
|
||||
classNames={{ input: "text-xs", inputWrapper: "h-8 min-h-8 px-1" }}
|
||||
/>
|
||||
<Input
|
||||
size="sm" variant="flat" type="number"
|
||||
value={Math.round(hsl.l).toString()}
|
||||
onChange={(e) => handleHslChange({ ...hsl, l: Number(e.target.value) })}
|
||||
endContent={<span className="text-xs text-default-400">L</span>}
|
||||
classNames={{ input: "text-xs", inputWrapper: "h-8 min-h-8 px-1" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1 mt-2 flex-wrap justify-between">
|
||||
{["#006FEE", "#17C964", "#F5A524", "#F31260", "#7828C8", "#000000", "#FFFFFF"].map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
className="w-6 h-6 rounded-full border border-default-200 shadow-sm transition-transform hover:scale-110 active:scale-95"
|
||||
style={{ backgroundColor: c }}
|
||||
onClick={() => handleHexChange(c)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export default ColorPicker;
|
||||
export default ColorPicker;
|
||||
@@ -500,10 +500,13 @@ const ThemeConfigCard = () => {
|
||||
return (
|
||||
<ColorPicker
|
||||
color={color}
|
||||
onChange={(result) => {
|
||||
onChange(
|
||||
`${result.hsl.h} ${result.hsl.s * 100}% ${result.hsl.l * 100}%`
|
||||
);
|
||||
onChange={(hslString) => {
|
||||
// ColorPicker returns hsl(h, s%, l%) string
|
||||
// We need to parse it and convert to "h s% l%" format for theme config
|
||||
const match = hslString.match(/hsl\((\d+(?:\.\d+)?),\s*(\d+(?:\.\d+)?)%,\s*(\d+(?:\.\d+)?)%\)/);
|
||||
if (match) {
|
||||
onChange(`${match[1]} ${match[2]}% ${match[3]}%`);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user