From 0592f1a99a0f493a7bc8bc0291e18958f873a6bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=89=8B=E7=93=9C=E4=B8=80=E5=8D=81=E9=9B=AA?= Date: Sun, 1 Feb 2026 14:22:52 +0800 Subject: [PATCH] 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. --- packages/napcat-webui-frontend/package.json | 1 - .../src/components/ColorPicker.tsx | 414 +++++++++++++++++- .../src/pages/dashboard/config/theme.tsx | 11 +- pnpm-lock.yaml | 48 -- 4 files changed, 400 insertions(+), 74 deletions(-) diff --git a/packages/napcat-webui-frontend/package.json b/packages/napcat-webui-frontend/package.json index b6a24714..33cd8f68 100644 --- a/packages/napcat-webui-frontend/package.json +++ b/packages/napcat-webui-frontend/package.json @@ -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", diff --git a/packages/napcat-webui-frontend/src/components/ColorPicker.tsx b/packages/napcat-webui-frontend/src/components/ColorPicker.tsx index 40fbb1f0..8c819f12 100644 --- a/packages/napcat-webui-frontend/src/components/ColorPicker.tsx +++ b/packages/napcat-webui-frontend/src/components/ColorPicker.tsx @@ -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(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 ( +
+
+
+ ); +}); + +SatLightPanel.displayName = "SatLightPanel"; + +const HueSlider = memo(({ hsl, onChange }: PanelProps) => { + const sliderRef = useRef(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 ( +
+
+
+ ); +}); + +HueSlider.displayName = "HueSlider"; + const ColorPicker: React.FC = ({ 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 ( - + -
+
+
+
+ {hex} + HSL({Math.round(hsl.h)}, {Math.round(hsl.s)}%, {Math.round(hsl.l)}%) +
+
- {/* 移除 PopoverContent 默认的事件阻止,允许鼠标拖动到外部 */} - - + { isDraggingRef.current = true; }} + onMouseUpCapture={() => { isDraggingRef.current = false; }} + onTouchStartCapture={() => { isDraggingRef.current = true; }} + onTouchEndCapture={() => { isDraggingRef.current = false; }} + > +
+
+ 选择颜色 +
+
+ + + + +
+ HEX + handleHexChange(e.target.value)} + className="col-span-3 font-mono" + classNames={{ + input: "text-xs uppercase", + inputWrapper: "h-8 min-h-8" + }} + /> +
+ +
+ HSL +
+ handleHslChange({ ...hsl, h: Number(e.target.value) })} + endContent={H} + classNames={{ input: "text-xs", inputWrapper: "h-8 min-h-8 px-1" }} + /> + handleHslChange({ ...hsl, s: Number(e.target.value) })} + endContent={S} + classNames={{ input: "text-xs", inputWrapper: "h-8 min-h-8 px-1" }} + /> + handleHslChange({ ...hsl, l: Number(e.target.value) })} + endContent={L} + classNames={{ input: "text-xs", inputWrapper: "h-8 min-h-8 px-1" }} + /> +
+
+ +
+ {["#006FEE", "#17C964", "#F5A524", "#F31260", "#7828C8", "#000000", "#FFFFFF"].map((c) => ( +
+
); }; -export default ColorPicker; +export default ColorPicker; \ No newline at end of file diff --git a/packages/napcat-webui-frontend/src/pages/dashboard/config/theme.tsx b/packages/napcat-webui-frontend/src/pages/dashboard/config/theme.tsx index e15e182a..18dc8063 100644 --- a/packages/napcat-webui-frontend/src/pages/dashboard/config/theme.tsx +++ b/packages/napcat-webui-frontend/src/pages/dashboard/config/theme.tsx @@ -500,10 +500,13 @@ const ThemeConfigCard = () => { return ( { - 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]}%`); + } }} /> ); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3afc2260..03c82e2d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -634,9 +634,6 @@ importers: react: specifier: ^19.0.0 version: 19.2.0 - react-color: - specifier: ^2.19.3 - version: 2.19.3(react@19.2.0) react-dom: specifier: ^19.0.0 version: 19.2.0(react@19.2.0) @@ -1859,11 +1856,6 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} - '@icons/material@0.2.4': - resolution: {integrity: sha512-QPcGmICAPbGLGb6F/yNf/KzKqvFx8z5qx3D1yFqVAjoFmXK35EgyW+cJ57Te3CNsmzblwtzakLGFqHPqrfb4Tw==} - peerDependencies: - react: '*' - '@img/colour@1.0.0': resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==} engines: {node: '>=18'} @@ -5154,9 +5146,6 @@ packages: markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} - material-colors@1.2.6: - resolution: {integrity: sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg==} - math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -5859,11 +5848,6 @@ packages: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true - react-color@2.19.3: - resolution: {integrity: sha512-LEeGE/ZzNLIsFWa1TMe8y5VYqr7bibneWmvJwm1pCn/eNmrabWDh659JSPn9BuaMpEfU83WTOJfnCcjDZwNQTA==} - peerDependencies: - react: '*' - react-dom@19.2.0: resolution: {integrity: sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==} peerDependencies: @@ -5969,11 +5953,6 @@ packages: resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==} engines: {node: '>=0.10.0'} - reactcss@1.2.3: - resolution: {integrity: sha512-KiwVUcFu1RErkI97ywr8nvx8dNOpT03rbnma0SSalTYjkrPYaEajR4a/MRt6DZ46K6arDRbWMNHF+xH7G7n/8A==} - peerDependencies: - react: '*' - read-cache@1.0.0: resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} @@ -6488,9 +6467,6 @@ packages: tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} - tinycolor2@1.6.0: - resolution: {integrity: sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==} - tinyexec@0.3.2: resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} @@ -8382,10 +8358,6 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} - '@icons/material@0.2.4(react@19.2.0)': - dependencies: - react: 19.2.0 - '@img/colour@1.0.0': {} '@img/sharp-darwin-arm64@0.34.5': @@ -12298,8 +12270,6 @@ snapshots: markdown-table@3.0.4: {} - material-colors@1.2.6: {} - math-intrinsics@1.1.0: {} mdast-util-find-and-replace@3.0.2: @@ -13214,17 +13184,6 @@ snapshots: minimist: 1.2.8 strip-json-comments: 2.0.1 - react-color@2.19.3(react@19.2.0): - dependencies: - '@icons/material': 0.2.4(react@19.2.0) - lodash: 4.17.21 - lodash-es: 4.17.21 - material-colors: 1.2.6 - prop-types: 15.8.1 - react: 19.2.0 - reactcss: 1.2.3(react@19.2.0) - tinycolor2: 1.6.0 - react-dom@19.2.0(react@19.2.0): dependencies: react: 19.2.0 @@ -13329,11 +13288,6 @@ snapshots: react@19.2.0: {} - reactcss@1.2.3(react@19.2.0): - dependencies: - lodash: 4.17.21 - react: 19.2.0 - read-cache@1.0.0: dependencies: pify: 2.3.0 @@ -14023,8 +13977,6 @@ snapshots: tinybench@2.9.0: {} - tinycolor2@1.6.0: {} - tinyexec@0.3.2: {} tinyglobby@0.2.15: