| 1 | import { useEffect, useRef, useState } from 'react' |
| 2 | import { Chrome, ChromeInputType, type ColorResult } from '@uiw/react-color' |
| 3 | import { Popover, PopoverContent, PopoverTrigger } from './Popover' |
| 4 | |
| 5 | interface ColorPickerProps { |
| 6 | value: string | undefined |
| 7 | onChange: (value: string) => void |
| 8 | onCommit?: (value: string) => void |
| 9 | className?: string |
| 10 | allowAlpha?: boolean |
| 11 | ariaLabel?: string |
| 12 | } |
| 13 | |
| 14 | function parseColor(value: string | undefined): { hex: string; alpha: number } { |
| 15 | if (!value) return { hex: '#000000', alpha: 1 } |
| 16 | |
| 17 | const rgbaMatch = value.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+))?\)$/) |
| 18 | if (rgbaMatch) { |
| 19 | const r = parseInt(rgbaMatch[1]) |
| 20 | const g = parseInt(rgbaMatch[2]) |
| 21 | const b = parseInt(rgbaMatch[3]) |
| 22 | const a = rgbaMatch[4] !== undefined ? parseFloat(rgbaMatch[4]) : 1 |
| 23 | const hex = '#' + [r, g, b].map((color) => color.toString(16).padStart(2, '0')).join('') |
| 24 | return { hex, alpha: Math.min(1, Math.max(0, Math.round(a * 100) / 100)) } |
| 25 | } |
| 26 | |
| 27 | if (value.startsWith('#')) { |
| 28 | if (value.length === 9) { |
| 29 | const hex = value.slice(0, 7) |
| 30 | const alpha = Math.round((parseInt(value.slice(7, 9), 16) / 255) * 100) / 100 |
| 31 | return { hex, alpha } |
| 32 | } |
| 33 | return { hex: value.slice(0, 7), alpha: 1 } |
| 34 | } |
| 35 | |
| 36 | return { hex: '#000000', alpha: 1 } |
| 37 | } |
| 38 | |
| 39 | function toRgbaString(hex: string, alpha: number): string { |
| 40 | const r = parseInt(hex.slice(1, 3), 16) |
| 41 | const g = parseInt(hex.slice(3, 5), 16) |
| 42 | const b = parseInt(hex.slice(5, 7), 16) |
| 43 | return `rgba(${r}, ${g}, ${b}, ${alpha})` |
| 44 | } |
| 45 | |
| 46 | function formatColor(hex: string, alpha: number): string { |
| 47 | if (alpha >= 1) return hex |
| 48 | return toRgbaString(hex, alpha) |
| 49 | } |
| 50 | |
| 51 | export function ColorPicker({ |
| 52 | value, |
| 53 | onChange, |
| 54 | onCommit, |
| 55 | className, |
| 56 | allowAlpha = true, |
| 57 | ariaLabel |
| 58 | }: ColorPickerProps): React.JSX.Element { |
| 59 | const { hex, alpha } = parseColor(value) |
| 60 | const [open, setOpen] = useState(false) |
| 61 | const [draft, setDraft] = useState({ hex, alpha: allowAlpha ? alpha : 1 }) |
| 62 | const latestColorRef = useRef(formatColor(hex, allowAlpha ? alpha : 1)) |
| 63 | |
| 64 | useEffect(() => { |
| 65 | const parsed = parseColor(value) |
| 66 | const next = { hex: parsed.hex, alpha: allowAlpha ? parsed.alpha : 1 } |
| 67 | setDraft(next) |
| 68 | latestColorRef.current = formatColor(next.hex, next.alpha) |
| 69 | }, [allowAlpha, value]) |
| 70 | |
| 71 | const handleChange = (color: ColorResult): void => { |
| 72 | const next = { |
| 73 | hex: color.hex.toLowerCase(), |
| 74 | alpha: allowAlpha ? Math.min(1, Math.max(0, Math.round(color.rgba.a * 100) / 100)) : 1 |
| 75 | } |
| 76 | const nextColor = formatColor(next.hex, next.alpha) |
| 77 | setDraft(next) |
| 78 | latestColorRef.current = nextColor |
| 79 | onChange(nextColor) |
| 80 | } |
| 81 | |
| 82 | const displayColor = formatColor(hex, allowAlpha ? alpha : 1) |
| 83 | |
| 84 | return ( |
| 85 | <div className={className}> |
| 86 | <Popover |
| 87 | open={open} |
| 88 | onOpenChange={(nextOpen) => { |
| 89 | setOpen(nextOpen) |
| 90 | if (!nextOpen) onCommit?.(latestColorRef.current) |
| 91 | }} |
| 92 | > |
| 93 | <PopoverTrigger asChild> |
| 94 | <button |
| 95 | type="button" |
| 96 | aria-label={ariaLabel} |
| 97 | title={value} |
| 98 | className="h-8 w-10 shrink-0 cursor-pointer rounded-md border border-[#d7cbb7]/70 bg-[#fffdf8] p-1 transition-colors hover:border-[#879b71] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#5d6b4d]" |
| 99 | > |
| 100 | <span |
| 101 | className="block h-full w-full rounded-[3px] shadow-[inset_0_0_0_1px_rgba(30,38,25,0.12)]" |
| 102 | style={{ |
| 103 | backgroundColor: displayColor, |
| 104 | backgroundImage: |
| 105 | allowAlpha && alpha < 1 |
| 106 | ? 'linear-gradient(45deg, #c9c9c9 25%, transparent 25%), linear-gradient(-45deg, #c9c9c9 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #c9c9c9 75%), linear-gradient(-45deg, transparent 75%, #c9c9c9 75%)' |
| 107 | : undefined, |
| 108 | backgroundSize: allowAlpha && alpha < 1 ? '6px 6px' : undefined, |
| 109 | backgroundPosition: allowAlpha && alpha < 1 ? '0 0, 0 3px, 3px -3px, -3px 0' : undefined |
| 110 | }} |
| 111 | /> |
| 112 | </button> |
| 113 | </PopoverTrigger> |
| 114 | <PopoverContent |
| 115 | className="color-picker-popover w-[244px] rounded-lg border border-[#d7cbb7]/70 bg-[#fffdf8] p-2 shadow-[0_14px_34px_-12px_rgba(66,53,36,0.3)]" |
| 116 | align="start" |
| 117 | sideOffset={8} |
| 118 | > |
| 119 | <Chrome |
| 120 | color={allowAlpha ? toRgbaString(draft.hex, draft.alpha) : draft.hex} |
| 121 | inputType={ChromeInputType.HEXA} |
| 122 | showAlpha={allowAlpha} |
| 123 | showEyeDropper |
| 124 | className="color-picker-chrome" |
| 125 | onChange={handleChange} |
| 126 | /> |
| 127 | <style>{` |
| 128 | .color-picker-popover .color-picker-chrome { |
| 129 | width: 100% !important; |
| 130 | border-radius: 6px !important; |
| 131 | overflow: hidden; |
| 132 | --github-background-color: #fffdf8; |
| 133 | --github-border: 0; |
| 134 | --github-box-shadow: none; |
| 135 | --chrome-arrow-fill: #6f7d62; |
| 136 | --chrome-arrow-background-color: #f2ece0; |
| 137 | --editable-input-label-color: #7b735f; |
| 138 | --editable-input-color: #3e4a32; |
| 139 | --editable-input-box-shadow: #d7cbb7 0 0 0 1px inset; |
| 140 | } |
| 141 | .color-picker-popover .w-color-saturation { |
| 142 | border-radius: 5px 5px 0 0; |
| 143 | cursor: crosshair; |
| 144 | } |
| 145 | .color-picker-popover .w-color-hue, |
| 146 | .color-picker-popover .w-color-alpha { |
| 147 | cursor: ew-resize; |
| 148 | } |
| 149 | .color-picker-popover .w-color-chrome svg, |
| 150 | .color-picker-popover .w-color-chrome button, |
| 151 | .color-picker-popover .w-color-chrome input { |
| 152 | cursor: pointer; |
| 153 | } |
| 154 | .color-picker-popover .w-color-chrome input { |
| 155 | border-radius: 4px !important; |
| 156 | } |
| 157 | `}</style> |
| 158 | </PopoverContent> |
| 159 | </Popover> |
| 160 | </div> |
| 161 | ) |
| 162 | } |
| 163 |