| 1 | import { memo, useCallback, useEffect, useRef, useState } from "react"; |
| 2 | import { isThemeColor, themeColorToHex, type ThemeColorRole } from "../../themePalette"; |
| 3 | import { cn } from "../../lib/utils"; |
| 4 | import { hexToHsv, hsvToHex, type HsvColor } from "../../lib/color"; |
| 5 | import { ColorHueSlider, ColorSaturationValuePlane } from "../ui/color-picker"; |
| 6 | import { Input } from "../ui/input"; |
| 7 | import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; |
| 8 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 9 | export function getThemeRoleLabel(role: ThemeColorRole): string { |
| 10 | const labels: Partial<Record<ThemeColorRole, string>> = { |
| 11 | canvas: "Background", |
| 12 | toolbar: "Toolbar background", |
| 13 | toolbarForeground: "Toolbar text", |
| 14 | toolbarBorder: "Toolbar border", |
| 15 | toolbarControl: "Toolbar control", |
| 16 | toolbarControlForeground: "Toolbar control text", |
| 17 | toolbarControlHover: "Toolbar control hover", |
| 18 | accent: "Accent color", |
| 19 | errorForeground: "Error text", |
| 20 | errorSurface: "Error background", |
| 21 | warningForeground: "Warning text", |
| 22 | warningSurface: "Warning background", |
| 23 | updateForeground: "Update text", |
| 24 | updateSurface: "Update background", |
| 25 | }; |
| 26 | const label = labels[role]; |
| 27 | if (label) return label; |
| 28 | return role.replace(/([A-Z])/g, " $1").replace(/^./, (character) => character.toUpperCase()); |
| 29 | } |
| 30 | |
| 31 | /** |
| 32 | * The picker remains an sRGB/hex adapter over the OKLCH palette engine. Alpha |
| 33 | * is preserved separately and re-attached on commit so adjusting hue or |
| 34 | * brightness cannot change transparency. |
| 35 | */ |
| 36 | function themePickerAlphaSuffix(value: string): string { |
| 37 | const normalized = themeColorToHex(value) ?? ""; |
| 38 | const alpha = normalized.length === 9 ? normalized.slice(7) : ""; |
| 39 | return alpha === "ff" ? "" : alpha; |
| 40 | } |
| 41 | |
| 42 | function normalizeThemePickerColor(value: string): string { |
| 43 | return (themeColorToHex(value) ?? "#000000").slice(0, 7); |
| 44 | } |
| 45 | |
| 46 | function themeHexToRgb(hex: string) { |
| 47 | const numeric = Number.parseInt(normalizeThemePickerColor(hex).slice(1), 16); |
| 48 | return [numeric >> 16, (numeric >> 8) & 255, numeric & 255] as const; |
| 49 | } |
| 50 | |
| 51 | function themeRgbToHex(value: string): string | null { |
| 52 | const normalized = value |
| 53 | .trim() |
| 54 | .replace(/^rgb\(\s*/i, "") |
| 55 | .replace(/\s*\)$/, ""); |
| 56 | const channels = normalized |
| 57 | .split(/[,\s]+/) |
| 58 | .filter(Boolean) |
| 59 | .map(Number); |
| 60 | if ( |
| 61 | channels.length !== 3 || |
| 62 | channels.some((channel) => !Number.isInteger(channel) || channel < 0 || channel > 255) |
| 63 | ) { |
| 64 | return null; |
| 65 | } |
| 66 | |
| 67 | return `#${channels.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`; |
| 68 | } |
| 69 | |
| 70 | function themeRgbValue(hex: string) { |
| 71 | return themeHexToRgb(hex).join(", "); |
| 72 | } |
| 73 | |
| 74 | function ThemeColorPickerPanel({ |
| 75 | label, |
| 76 | value, |
| 77 | onChange, |
| 78 | }: { |
| 79 | label: string; |
| 80 | value: string; |
| 81 | onChange: (value: string) => void; |
| 82 | }) { |
| 83 | const normalizedValue = normalizeThemePickerColor(value); |
| 84 | const alphaSuffix = themePickerAlphaSuffix(value); |
| 85 | const [hsv, setHsv] = useState(() => hexToHsv(normalizedValue)); |
| 86 | const [hexDraft, setHexDraft] = useState(normalizedValue); |
| 87 | const [rgbDraft, setRgbDraft] = useState(() => themeRgbValue(normalizedValue)); |
| 88 | const isEditingTextRef = useRef(false); |
| 89 | const currentColor = hsvToHex(hsv.h, hsv.s, hsv.v); |
| 90 | const currentRgb = themeRgbValue(currentColor); |
| 91 | |
| 92 | useEffect(() => { |
| 93 | // While a text field is focused, the incoming value may be the guided |
| 94 | // editor's readability-adjusted echo of what is being typed; rewriting the |
| 95 | // draft would fight the keystrokes. The swatch still tracks via hsv. |
| 96 | if (!isEditingTextRef.current) { |
| 97 | setHexDraft(normalizedValue); |
| 98 | setRgbDraft(themeRgbValue(normalizedValue)); |
| 99 | } |
| 100 | // Keep the current hue/saturation when the incoming value is just our own |
| 101 | // change echoed back; hex → HSV is lossy for greys, white, and black. |
| 102 | setHsv((current) => |
| 103 | hsvToHex(current.h, current.s, current.v) === normalizedValue |
| 104 | ? current |
| 105 | : hexToHsv(normalizedValue), |
| 106 | ); |
| 107 | }, [normalizedValue]); |
| 108 | |
| 109 | // Local state updates immediately for a smooth thumb; the parent commit |
| 110 | // (which can regenerate a whole guided palette) is batched to one call per |
| 111 | // animation frame. |
| 112 | const onChangeRef = useRef(onChange); |
| 113 | onChangeRef.current = onChange; |
| 114 | const pendingCommitRef = useRef<string | null>(null); |
| 115 | const commitFrameRef = useRef<number | null>(null); |
| 116 | // The final drag frame must not be lost when the popover closes or the |
| 117 | // pointer lifts before the animation frame fires. |
| 118 | const flushPendingCommit = useCallback(() => { |
| 119 | if (commitFrameRef.current !== null) { |
| 120 | cancelAnimationFrame(commitFrameRef.current); |
| 121 | commitFrameRef.current = null; |
| 122 | } |
| 123 | const pending = pendingCommitRef.current; |
| 124 | pendingCommitRef.current = null; |
| 125 | if (pending !== null) onChangeRef.current(pending); |
| 126 | }, []); |
| 127 | useEffect(() => () => flushPendingCommit(), [flushPendingCommit]); |
| 128 | const scheduleCommit = useCallback((color: string) => { |
| 129 | pendingCommitRef.current = color; |
| 130 | commitFrameRef.current ??= requestAnimationFrame(() => { |
| 131 | commitFrameRef.current = null; |
| 132 | const pending = pendingCommitRef.current; |
| 133 | pendingCommitRef.current = null; |
| 134 | if (pending !== null) onChangeRef.current(pending); |
| 135 | }); |
| 136 | }, []); |
| 137 | |
| 138 | const commitHsv = useCallback( |
| 139 | (nextHsv: HsvColor) => { |
| 140 | setHsv(nextHsv); |
| 141 | const nextColor = hsvToHex(nextHsv.h, nextHsv.s, nextHsv.v); |
| 142 | setHexDraft(nextColor); |
| 143 | setRgbDraft(themeRgbValue(nextColor)); |
| 144 | scheduleCommit(nextColor + alphaSuffix); |
| 145 | }, |
| 146 | [alphaSuffix, scheduleCommit], |
| 147 | ); |
| 148 | |
| 149 | const handleHexChange = (nextValue: string) => { |
| 150 | setHexDraft(nextValue); |
| 151 | if (!/^#[0-9a-f]{6}$/i.test(nextValue)) return; |
| 152 | const nextHsv = hexToHsv(nextValue); |
| 153 | setHsv(nextHsv); |
| 154 | setRgbDraft(themeRgbValue(nextValue)); |
| 155 | onChange(nextValue.toLowerCase()); |
| 156 | }; |
| 157 | |
| 158 | const handleRgbChange = (nextValue: string) => { |
| 159 | setRgbDraft(nextValue); |
| 160 | const nextColor = themeRgbToHex(nextValue); |
| 161 | if (!nextColor) return; |
| 162 | setHsv(hexToHsv(nextColor)); |
| 163 | setHexDraft(nextColor); |
| 164 | // RGB cannot express alpha, so a commit keeps the incoming suffix just |
| 165 | // like the plane and hue controls do. |
| 166 | onChange(nextColor + alphaSuffix); |
| 167 | }; |
| 168 | |
| 169 | return ( |
| 170 | <div className="w-72 bg-popover"> |
| 171 | <div className="flex items-center justify-between border-b border-border/70 px-4 py-3"> |
| 172 | <div className="min-w-0"> |
| 173 | <p className="truncate text-xs font-semibold text-foreground">{label}</p> |
| 174 | <p className="text-2xs text-muted-foreground">Choose a color</p> |
| 175 | </div> |
| 176 | <span |
| 177 | className="size-7 shrink-0 rounded-full shadow-sm" |
| 178 | style={{ backgroundColor: currentColor }} |
| 179 | /> |
| 180 | </div> |
| 181 | <div className="grid gap-3 px-3 pb-3 pt-3"> |
| 182 | <ColorSaturationValuePlane |
| 183 | label={label} |
| 184 | value={hsv} |
| 185 | onChange={commitHsv} |
| 186 | onInteractionEnd={flushPendingCommit} |
| 187 | /> |
| 188 | <ColorHueSlider |
| 189 | label={`${label} hue`} |
| 190 | value={hsv.h} |
| 191 | onChange={(h) => commitHsv({ ...hsv, h })} |
| 192 | onInteractionEnd={flushPendingCommit} |
| 193 | /> |
| 194 | <div className="grid grid-cols-[1fr_1.2fr] gap-2"> |
| 195 | <label className="grid min-w-0 gap-1"> |
| 196 | <span className="px-1 text-3xs font-semibold uppercase tracking-widest text-muted-foreground"> |
| 197 | HEX |
| 198 | </span> |
| 199 | <span className="flex min-w-0 items-center gap-2 rounded-lg border border-input bg-background px-2 focus-within:border-ring"> |
| 200 | <span |
| 201 | className="size-3.5 shrink-0 rounded-full" |
| 202 | style={{ backgroundColor: currentColor }} |
| 203 | /> |
| 204 | <input |
| 205 | aria-label={`${label} picker hex value`} |
| 206 | className="h-8 min-w-0 flex-1 bg-transparent font-mono text-xs text-foreground outline-none" |
| 207 | onBlur={() => { |
| 208 | isEditingTextRef.current = false; |
| 209 | setHexDraft(currentColor); |
| 210 | setRgbDraft(currentRgb); |
| 211 | }} |
| 212 | onChange={(event) => handleHexChange(event.currentTarget.value)} |
| 213 | onFocus={() => { |
| 214 | isEditingTextRef.current = true; |
| 215 | }} |
| 216 | spellCheck={false} |
| 217 | value={hexDraft} |
| 218 | /> |
| 219 | </span> |
| 220 | </label> |
| 221 | <label className="grid min-w-0 gap-1"> |
| 222 | <span className="px-1 text-3xs font-semibold uppercase tracking-widest text-muted-foreground"> |
| 223 | RGB |
| 224 | </span> |
| 225 | <span className="flex min-w-0 items-center rounded-lg border border-input bg-background px-2 focus-within:border-ring"> |
| 226 | <input |
| 227 | aria-label={`${label} picker RGB value`} |
| 228 | className="h-8 min-w-0 flex-1 bg-transparent font-mono text-xs text-foreground outline-none" |
| 229 | onBlur={() => { |
| 230 | isEditingTextRef.current = false; |
| 231 | setHexDraft(currentColor); |
| 232 | setRgbDraft(currentRgb); |
| 233 | }} |
| 234 | onChange={(event) => handleRgbChange(event.currentTarget.value)} |
| 235 | onFocus={() => { |
| 236 | isEditingTextRef.current = true; |
| 237 | }} |
| 238 | spellCheck={false} |
| 239 | value={rgbDraft} |
| 240 | /> |
| 241 | </span> |
| 242 | </label> |
| 243 | </div> |
| 244 | </div> |
| 245 | </div> |
| 246 | ); |
| 247 | } |
| 248 | |
| 249 | function ThemeColorPicker({ |
| 250 | label, |
| 251 | value, |
| 252 | onChange, |
| 253 | onInteract, |
| 254 | }: { |
| 255 | label: string; |
| 256 | value: string; |
| 257 | onChange: (value: string) => void; |
| 258 | onInteract?: () => void; |
| 259 | }) { |
| 260 | return ( |
| 261 | <Popover> |
| 262 | <Tooltip> |
| 263 | <TooltipTrigger |
| 264 | render={ |
| 265 | <PopoverTrigger |
| 266 | render={ |
| 267 | <button |
| 268 | aria-label={`Choose ${label} color`} |
| 269 | className="relative flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-full border border-foreground/30 transition-transform hover:scale-105 focus-visible:outline-2 focus-visible:outline-solid focus-visible:-outline-offset-2 focus-visible:outline-ring" |
| 270 | onFocus={onInteract} |
| 271 | onPointerDown={onInteract} |
| 272 | type="button" |
| 273 | > |
| 274 | <span |
| 275 | className="absolute inset-0 rounded-full shadow-sm" |
| 276 | style={{ backgroundColor: value }} |
| 277 | /> |
| 278 | </button> |
| 279 | } |
| 280 | /> |
| 281 | } |
| 282 | /> |
| 283 | <TooltipPopup side="top">{`Choose ${label} color`}</TooltipPopup> |
| 284 | </Tooltip> |
| 285 | <PopoverPopup |
| 286 | align="end" |
| 287 | padding="none" |
| 288 | data-theme-editor-panel="" |
| 289 | side="bottom" |
| 290 | sideOffset={10} |
| 291 | > |
| 292 | <ThemeColorPickerPanel label={label} onChange={onChange} value={value} /> |
| 293 | </PopoverPopup> |
| 294 | </Popover> |
| 295 | ); |
| 296 | } |
| 297 | |
| 298 | export const ThemeColorField = memo(function ThemeColorField({ |
| 299 | role, |
| 300 | value, |
| 301 | onChange, |
| 302 | onSelect, |
| 303 | onToggleSelected, |
| 304 | selected = false, |
| 305 | label: customLabel, |
| 306 | }: { |
| 307 | role: ThemeColorRole; |
| 308 | value: string; |
| 309 | onChange: (role: ThemeColorRole, value: string) => void; |
| 310 | onSelect?: (role: ThemeColorRole) => void; |
| 311 | onToggleSelected?: (role: ThemeColorRole) => void; |
| 312 | selected?: boolean; |
| 313 | label?: string; |
| 314 | }) { |
| 315 | const label = customLabel ?? getThemeRoleLabel(role); |
| 316 | const isColorValue = isThemeColor(value); |
| 317 | const swatchValue = isColorValue ? value : "#000000"; |
| 318 | const editorValue = value.trim().toLowerCase().startsWith("oklch(") |
| 319 | ? (themeColorToHex(value) ?? value) |
| 320 | : value; |
| 321 | |
| 322 | return ( |
| 323 | <div |
| 324 | className={cn( |
| 325 | "flex min-h-11 min-w-0 items-center gap-2 rounded-lg px-2 py-1.5 transition-[background-color,box-shadow]", |
| 326 | selected && "bg-accent/60 inset-ring inset-ring-ring", |
| 327 | )} |
| 328 | data-theme-color-role={role} |
| 329 | > |
| 330 | <Tooltip> |
| 331 | <TooltipTrigger |
| 332 | render={ |
| 333 | <button |
| 334 | aria-label={`${selected ? "Hide" : "Show"} ${label} usage`} |
| 335 | aria-pressed={selected} |
| 336 | className="flex min-w-0 flex-1 cursor-pointer items-center rounded-md text-left text-sm text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring" |
| 337 | onClick={() => onToggleSelected?.(role)} |
| 338 | type="button" |
| 339 | > |
| 340 | <span className="min-w-0 flex-1 truncate">{label}</span> |
| 341 | </button> |
| 342 | } |
| 343 | /> |
| 344 | <TooltipPopup side="top">{`${selected ? "Hide" : "Show"} where ${label} is used`}</TooltipPopup> |
| 345 | </Tooltip> |
| 346 | <div className="ml-auto flex shrink-0 items-center gap-2"> |
| 347 | <ThemeColorPicker |
| 348 | label={label} |
| 349 | onChange={(nextValue) => onChange(role, nextValue)} |
| 350 | onInteract={() => onSelect?.(role)} |
| 351 | value={swatchValue} |
| 352 | /> |
| 353 | <Input |
| 354 | aria-invalid={!isColorValue} |
| 355 | aria-label={`${label} hex value`} |
| 356 | className="w-28 shrink-0" |
| 357 | font="mono" |
| 358 | id={`${role}-hex`} |
| 359 | nativeInput |
| 360 | onChange={(event) => onChange(role, event.currentTarget.value)} |
| 361 | onFocus={() => onSelect?.(role)} |
| 362 | onPointerDown={() => onSelect?.(role)} |
| 363 | size="compact" |
| 364 | value={editorValue} |
| 365 | /> |
| 366 | </div> |
| 367 | </div> |
| 368 | ); |
| 369 | }); |