diff --git a/src/renderer/src/components/settings/sections/spaces/color-picker.tsx b/src/renderer/src/components/settings/sections/spaces/color-picker.tsx index ce0f122b..9349311e 100644 --- a/src/renderer/src/components/settings/sections/spaces/color-picker.tsx +++ b/src/renderer/src/components/settings/sections/spaces/color-picker.tsx @@ -1,7 +1,10 @@ -import { useRef, useState } from "react"; +import { useRef, useState, useEffect, useCallback, useId } from "react"; import { Label } from "@/components/ui/label"; -import { motion } from "motion/react"; -import { Palette } from "lucide-react"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { motion, AnimatePresence } from "motion/react"; +import { Palette, Edit3, Check, X, Pipette } from "lucide-react"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; // ============================== // ColorPicker Component @@ -10,123 +13,377 @@ interface ColorPickerProps { defaultColor: string; label: string; onChange: (color: string) => void; + disabled?: boolean; + className?: string; } -export function ColorPicker({ defaultColor, label, onChange }: ColorPickerProps) { - const inputRef = useRef(null); - const [previewColor, setPreviewColor] = useState(defaultColor || "#ffffff"); - const [isFocused, setIsFocused] = useState(false); +export function ColorPicker({ + defaultColor, + label, + onChange, + disabled = false, + className = "" +}: ColorPickerProps) { + const colorInputRef = useRef(null); + const textInputRef = useRef(null); + const timeoutRef = useRef(null); - const colorChangeTimeoutRef = useRef(null); + // Consolidated state + const [state, setState] = useState({ + currentColor: defaultColor || "#ffffff", + textValue: defaultColor || "#ffffff", + isFocused: false, + isEditingText: false, + validationError: "" + }); - // Handle color change without re-rendering on every input - const handleColorChange = () => { - if (colorChangeTimeoutRef.current) { - clearTimeout(colorChangeTimeoutRef.current); + // Sync with defaultColor changes + useEffect(() => { + const newColor = defaultColor || "#ffffff"; + setState(prev => ({ + ...prev, + currentColor: newColor, + textValue: newColor + })); + }, [defaultColor]); + + // Cleanup timeout on unmount + useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, []); + + // Utility functions + const isValidHexColor = useCallback((hex: string): boolean => { + // More strict hex validation - exactly 3 or 6 characters after # + return /^#([A-Fa-f0-9]{3}|[A-Fa-f0-9]{6})$/.test(hex); + }, []); + + const normalizeHexColor = useCallback((hex: string): string => { + if (hex.length === 4) { + return "#" + hex[1].repeat(2) + hex[2].repeat(2) + hex[3].repeat(2); + } + return hex; + }, []); + + const updateState = useCallback((updates: Partial) => { + setState(prev => ({ ...prev, ...updates })); + }, []); + + // Debounced onChange call with cleanup + const debouncedOnChange = useCallback((color: string) => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + timeoutRef.current = setTimeout(() => { + onChange(color); + timeoutRef.current = null; + }, 100); + }, [onChange]); + + // Color picker handlers + const handleColorPickerChange = useCallback((e: React.ChangeEvent) => { + if (disabled) return; + + const newColor = e.target.value; + updateState({ + currentColor: newColor, + textValue: newColor, + validationError: "" + }); + debouncedOnChange(newColor); + }, [updateState, debouncedOnChange, disabled]); + + const handleColorPickerFocus = useCallback(() => { + if (!disabled) { + updateState({ isFocused: true }); + } + }, [updateState, disabled]); + + const handleColorPickerBlur = useCallback(() => { + updateState({ isFocused: false }); + if (colorInputRef.current) { + onChange(colorInputRef.current.value); + } + }, [updateState, onChange]); + + // Text input handlers + const handleTextChange = useCallback((e: React.ChangeEvent) => { + if (disabled) return; + + const value = e.target.value; + const normalizedValue = value.startsWith("#") ? value : "#" + value; + + const error = normalizedValue !== "#" && !isValidHexColor(normalizedValue) + ? "Invalid hex color format (use #RGB or #RRGGBB)" + : ""; + + updateState({ + textValue: value, + validationError: error + }); + }, [updateState, isValidHexColor, disabled]); + + // Text editing actions + const cancelTextEdit = useCallback(() => { + updateState({ + textValue: state.currentColor, + isEditingText: false, + validationError: "" + }); + }, [state.currentColor, updateState]); + + const applyTextChanges = useCallback(() => { + const { textValue } = state; + + if (!textValue || textValue === "#") { + cancelTextEdit(); + return; } - if (inputRef.current) { - const newColor = inputRef.current.value; + const normalizedValue = textValue.startsWith("#") ? textValue : "#" + textValue; - colorChangeTimeoutRef.current = setTimeout(() => { - setPreviewColor(newColor); - onChange(newColor); - }, 100); + if (!isValidHexColor(normalizedValue)) { + updateState({ + validationError: "Invalid hex color format (use #RGB or #RRGGBB)", + textValue: state.currentColor + }); + return; } - }; - - // Update preview color without triggering re-renders in parent - const handlePreviewUpdate = (e: React.ChangeEvent) => { - setPreviewColor(e.target.value); - }; - - // Only call onChange when focus is lost (user is done selecting) - const handleBlur = () => { - setIsFocused(false); - if (inputRef.current) { - onChange(inputRef.current.value); + + const finalColor = normalizeHexColor(normalizedValue); + + updateState({ + currentColor: finalColor, + textValue: finalColor, + isEditingText: false, + validationError: "" + }); + + if (colorInputRef.current) { + colorInputRef.current.value = finalColor; } - }; - const handleFocus = () => { - setIsFocused(true); - }; + onChange(finalColor); + }, [state, updateState, isValidHexColor, normalizeHexColor, onChange, cancelTextEdit]); - // Get contrasting text color for the preview - const getContrastingTextColor = (hexColor: string) => { - // Convert hex to RGB - const r = parseInt(hexColor.slice(1, 3), 16); - const g = parseInt(hexColor.slice(3, 5), 16); - const b = parseInt(hexColor.slice(5, 7), 16); + const handleTextKeyPress = useCallback((e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + applyTextChanges(); + } else if (e.key === "Escape") { + e.preventDefault(); + cancelTextEdit(); + } + }, [applyTextChanges, cancelTextEdit]); + + const handleTextBlur = useCallback(() => { + if (state.isEditingText) { + cancelTextEdit(); + } + }, [state.isEditingText, cancelTextEdit]); - // Calculate luminance - const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; + const toggleEditMode = useCallback(() => { + if (disabled) return; - return luminance > 0.5 ? "#000000" : "#ffffff"; - }; + if (state.isEditingText) { + applyTextChanges(); + } else { + updateState({ isEditingText: true }); + // Focus and select text after state update + requestAnimationFrame(() => { + textInputRef.current?.focus(); + textInputRef.current?.select(); + }); + } + }, [state.isEditingText, updateState, applyTextChanges, disabled]); - const textColor = getContrastingTextColor(previewColor); + // Generate unique component ID + const componentId = useId(); return ( -
-