From c46d28891fee62ca2d3132da876af6411a41d9da Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Wed, 15 Apr 2026 15:45:50 +0200 Subject: feat: text / custom masks, fix preview pan, refactor selection --- apps/web/src/components/ui/color-picker.tsx | 1029 +++++++++++++++------------ 1 file changed, 560 insertions(+), 469 deletions(-) (limited to 'apps/web/src/components/ui') diff --git a/apps/web/src/components/ui/color-picker.tsx b/apps/web/src/components/ui/color-picker.tsx index 2bc7ec56..3b28ac6f 100644 --- a/apps/web/src/components/ui/color-picker.tsx +++ b/apps/web/src/components/ui/color-picker.tsx @@ -1,469 +1,560 @@ -import { forwardRef, useEffect, useRef, useState } from "react"; -import { cn } from "@/utils/ui"; -import { Input } from "./input"; -import { - Popover, - PopoverClose, - PopoverContent, - PopoverTrigger, -} from "./popover"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "./select"; -import { Button } from "./button"; -import { Cancel01Icon, ColorPickerIcon } from "@hugeicons/core-free-icons"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { - type ColorFormat, - appendAlpha, - extractColorFromText, - formatColorValue, - hexToHsv, - hsvToHex, - parseColorInput, - parseHexAlpha, -} from "@/utils/color"; - -interface ColorPickerProps { - value?: string; - onChange?: (value: string) => void; - onChangeEnd?: (value: string) => void; - className?: string; -} - -const ColorPicker = forwardRef( - ({ className, value = "FFFFFF", onChange, onChangeEnd, ...props }, ref) => { - const [isDragging, setIsDragging] = useState< - "saturation" | "hue" | "opacity" | null - >(null); - const [internalHue, setInternalHue] = useState(0); - const [inputValue, setInputValue] = useState(value); - const [colorFormat, setColorFormat] = useState("hex"); - - const saturationRef = useRef(null); - const hueRef = useRef(null); - const opacityRef = useRef(null); - const latestDragColorRef = useRef(null); - - const isEyeDropperSupported = - typeof window !== "undefined" && "EyeDropper" in window; - - const { rgb: rgbValue, alpha } = parseHexAlpha({ hex: value }); - const [h, s, v] = hexToHsv({ hex: rgbValue }); - - const handleEyeDropper = async () => { - if (!isEyeDropperSupported || !EyeDropper) return; - try { - const dropper = new EyeDropper(); - const result = await dropper.open(); - const hex = result.sRGBHex.replace("#", "").toLowerCase(); - const finalHex = appendAlpha({ rgbHex: hex, alpha }); - onChange?.(finalHex); - onChangeEnd?.(finalHex); - } catch { - // user cancelled the picker - } - }; - const hueDiff = Math.abs(h - internalHue); - const isSameHueWrapped = hueDiff < 1 || Math.abs(hueDiff - 360) < 1; - const displayHue = s === 0 || isSameHueWrapped ? internalHue : h; - - useEffect(() => { - setInputValue(formatColorValue({ hex: value, format: colorFormat })); - }, [value, colorFormat]); - - useEffect(() => { - const handleMouseMove = (e: MouseEvent) => { - if (!isDragging) return; - - if (isDragging === "saturation" && saturationRef.current) { - const rect = saturationRef.current.getBoundingClientRect(); - const x = Math.max( - 0, - Math.min(1, (e.clientX - rect.left) / rect.width), - ); - const y = Math.max( - 0, - Math.min(1, (e.clientY - rect.top) / rect.height), - ); - const newHex = appendAlpha({ - rgbHex: hsvToHex({ h: displayHue, s: x, v: 1 - y }), - alpha, - }); - latestDragColorRef.current = newHex; - onChange?.(newHex); - } - - if (isDragging === "hue" && hueRef.current) { - const rect = hueRef.current.getBoundingClientRect(); - const x = Math.max( - 0, - Math.min(1, (e.clientX - rect.left) / rect.width), - ); - const newH = x * 360; - setInternalHue(newH); - if (s > 0) { - const newHex = appendAlpha({ - rgbHex: hsvToHex({ h: newH, s, v }), - alpha, - }); - latestDragColorRef.current = newHex; - onChange?.(newHex); - } - } - - if (isDragging === "opacity" && opacityRef.current) { - const rect = opacityRef.current.getBoundingClientRect(); - const x = Math.max( - 0, - Math.min(1, (e.clientX - rect.left) / rect.width), - ); - const newHex = appendAlpha({ rgbHex: rgbValue, alpha: x }); - latestDragColorRef.current = newHex; - onChange?.(newHex); - } - }; - - const handleMouseUp = () => { - if (latestDragColorRef.current !== null && onChangeEnd) { - onChangeEnd(latestDragColorRef.current); - latestDragColorRef.current = null; - } - setIsDragging(null); - }; - - if (isDragging) { - document.addEventListener("mousemove", handleMouseMove); - document.addEventListener("mouseup", handleMouseUp); - return () => { - document.removeEventListener("mousemove", handleMouseMove); - document.removeEventListener("mouseup", handleMouseUp); - }; - } - }, [isDragging, displayHue, s, v, alpha, rgbValue, onChange, onChangeEnd]); - - const handleSaturationMouseDown = (e: React.MouseEvent) => { - e.preventDefault(); - const saturationElement = saturationRef.current; - if (!saturationElement) return; - setIsDragging("saturation"); - const rect = saturationElement.getBoundingClientRect(); - const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); - const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height)); - const newHex = appendAlpha({ - rgbHex: hsvToHex({ h: displayHue, s: x, v: 1 - y }), - alpha, - }); - latestDragColorRef.current = newHex; - onChange?.(newHex); - }; - - const handleHueMouseDown = (e: React.MouseEvent) => { - e.preventDefault(); - const hueElement = hueRef.current; - if (!hueElement) return; - setIsDragging("hue"); - const rect = hueElement.getBoundingClientRect(); - const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); - const newH = x * 360; - setInternalHue(newH); - if (s > 0) { - const newHex = appendAlpha({ - rgbHex: hsvToHex({ h: newH, s, v }), - alpha, - }); - latestDragColorRef.current = newHex; - onChange?.(newHex); - } - }; - - const handleOpacityMouseDown = (e: React.MouseEvent) => { - e.preventDefault(); - const opacityElement = opacityRef.current; - if (!opacityElement) return; - setIsDragging("opacity"); - const rect = opacityElement.getBoundingClientRect(); - const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); - const newHex = appendAlpha({ rgbHex: rgbValue, alpha: x }); - latestDragColorRef.current = newHex; - onChange?.(newHex); - }; - - const handleInputChange = (e: React.ChangeEvent) => { - setInputValue( - colorFormat === "hex" - ? e.target.value.replace("#", "") - : e.target.value, - ); - }; - - const commitInputValue = () => { - const parsed = parseColorInput({ - input: inputValue, - format: colorFormat, - }); - if (parsed) { - const nextHex = appendAlpha({ rgbHex: parsed, alpha }); - onChange?.(nextHex); - onChangeEnd?.(nextHex); - return; - } - - const extracted = extractColorFromText({ text: inputValue }); - if (extracted) { - const hasExplicitAlpha = extracted.length > 6; - const finalHex = hasExplicitAlpha - ? extracted - : appendAlpha({ rgbHex: extracted, alpha }); - onChange?.(finalHex); - onChangeEnd?.(finalHex); - } - }; - - const handleInputBlur = () => { - commitInputValue(); - }; - - const handleInputKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter") { - commitInputValue(); - e.currentTarget.blur(); - } - }; - - const handlePaste = (event: React.ClipboardEvent) => { - const pastedText = event.clipboardData.getData("text"); - const extractedHex = extractColorFromText({ text: pastedText }); - if (!extractedHex) return; - - event.preventDefault(); - const hasExplicitAlpha = extractedHex.length > 6; - const finalHex = hasExplicitAlpha - ? extractedHex - : appendAlpha({ rgbHex: extractedHex, alpha }); - onChange?.(finalHex); - onChangeEnd?.(finalHex); - }; - - const saturationStyle = { - background: `linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, hsl(${displayHue}, 100%, 50%))`, - }; - - const hueStyle = { - background: - "linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%)", - }; - - const checkerboardStyle = { - backgroundImage: ` - linear-gradient(45deg, rgba(0,0,0,0.1) 25%, transparent 25%), - linear-gradient(-45deg, rgba(0,0,0,0.1) 25%, transparent 25%), - linear-gradient(45deg, transparent 75%, rgba(0,0,0,0.1) 75%), - linear-gradient(-45deg, transparent 75%, rgba(0,0,0,0.1) 75%) - `, - backgroundSize: "8px 8px", - backgroundPosition: "0 0, 0 4px, 4px -4px, -4px 0px", - backgroundColor: "#fff", - }; - - - return ( - -
- - - -
- -
-
- { - event.preventDefault(); - }} - onCloseAutoFocus={(event) => { - event.preventDefault(); - }} - onInteractOutside={(event) => { - if (isDragging) event.preventDefault(); - }} - > -
- -
- {isEyeDropperSupported && ( - - )} - - - -
-
-
- - - - - - -
- - - -
-
-
-
- ); - }, -); -ColorPicker.displayName = "ColorPicker"; - -const ColorCircle = ({ - size, - position, - color, -}: { - size: "sm" | "md"; - position: { left: string; top: string }; - color?: string; -}) => ( -
-); - -export { ColorPicker }; +import { type ComponentProps, forwardRef, useEffect, useRef, useState } from "react"; +import { cn } from "@/utils/ui"; +import { Input } from "./input"; +import { + Popover, + PopoverClose, + PopoverContent, + PopoverTrigger, +} from "./popover"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "./select"; +import { Button } from "./button"; +import { Cancel01Icon, ColorPickerIcon } from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { + type ColorFormat, + appendAlpha, + extractColorFromText, + formatColorValue, + hexToHsv, + hsvToHex, + parseColorInput, + parseHexAlpha, +} from "@/utils/color"; + +const CHECKERBOARD_STYLE = { + backgroundImage: ` + linear-gradient(45deg, rgba(0,0,0,0.1) 25%, transparent 25%), + linear-gradient(-45deg, rgba(0,0,0,0.1) 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, rgba(0,0,0,0.1) 75%), + linear-gradient(-45deg, transparent 75%, rgba(0,0,0,0.1) 75%) + `, + backgroundSize: "8px 8px", + backgroundPosition: "0 0, 0 4px, 4px -4px, -4px 0px", + backgroundColor: "#fff", +} as const; + +interface ColorPickerContentProps { + value?: string; + onChange?: (value: string) => void; + onChangeEnd?: (value: string) => void; + side?: ComponentProps["side"]; + align?: ComponentProps["align"]; +} + +function ColorPickerContent({ + value = "FFFFFF", + onChange, + onChangeEnd, + side = "left", + align = "center", +}: ColorPickerContentProps) { + const [isDragging, setIsDragging] = useState< + "saturation" | "hue" | "opacity" | null + >(null); + const [internalHue, setInternalHue] = useState(0); + const [inputValue, setInputValue] = useState(value); + const [colorFormat, setColorFormat] = useState("hex"); + + const saturationRef = useRef(null); + const hueRef = useRef(null); + const opacityRef = useRef(null); + const latestDragColorRef = useRef(null); + + const isEyeDropperSupported = + typeof window !== "undefined" && "EyeDropper" in window; + + const { rgb: rgbValue, alpha } = parseHexAlpha({ hex: value }); + const [h, s, v] = hexToHsv({ hex: rgbValue }); + + const hueDiff = Math.abs(h - internalHue); + const isSameHueWrapped = hueDiff < 1 || Math.abs(hueDiff - 360) < 1; + const displayHue = s === 0 || isSameHueWrapped ? internalHue : h; + + useEffect(() => { + setInputValue(formatColorValue({ hex: value, format: colorFormat })); + }, [value, colorFormat]); + + useEffect(() => { + const handleMouseMove = (event: MouseEvent) => { + if (!isDragging) return; + + if (isDragging === "saturation" && saturationRef.current) { + const rect = saturationRef.current.getBoundingClientRect(); + const x = Math.max( + 0, + Math.min(1, (event.clientX - rect.left) / rect.width), + ); + const y = Math.max( + 0, + Math.min(1, (event.clientY - rect.top) / rect.height), + ); + const newHex = appendAlpha({ + rgbHex: hsvToHex({ h: displayHue, s: x, v: 1 - y }), + alpha, + }); + latestDragColorRef.current = newHex; + onChange?.(newHex); + } + + if (isDragging === "hue" && hueRef.current) { + const rect = hueRef.current.getBoundingClientRect(); + const x = Math.max( + 0, + Math.min(1, (event.clientX - rect.left) / rect.width), + ); + const newH = x * 360; + setInternalHue(newH); + if (s > 0) { + const newHex = appendAlpha({ + rgbHex: hsvToHex({ h: newH, s, v }), + alpha, + }); + latestDragColorRef.current = newHex; + onChange?.(newHex); + } + } + + if (isDragging === "opacity" && opacityRef.current) { + const rect = opacityRef.current.getBoundingClientRect(); + const x = Math.max( + 0, + Math.min(1, (event.clientX - rect.left) / rect.width), + ); + const newHex = appendAlpha({ rgbHex: rgbValue, alpha: x }); + latestDragColorRef.current = newHex; + onChange?.(newHex); + } + }; + + const handleMouseUp = () => { + if (latestDragColorRef.current !== null && onChangeEnd) { + onChangeEnd(latestDragColorRef.current); + latestDragColorRef.current = null; + } + setIsDragging(null); + }; + + if (isDragging) { + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + } + }, [isDragging, displayHue, s, v, alpha, rgbValue, onChange, onChangeEnd]); + + const handleEyeDropper = async () => { + if (!isEyeDropperSupported || !EyeDropper) return; + try { + const dropper = new EyeDropper(); + const result = await dropper.open(); + const hex = result.sRGBHex.replace("#", "").toLowerCase(); + const finalHex = appendAlpha({ rgbHex: hex, alpha }); + onChange?.(finalHex); + onChangeEnd?.(finalHex); + } catch { + // user cancelled the picker + } + }; + + const handleSaturationMouseDown = (event: React.MouseEvent) => { + event.preventDefault(); + const saturationElement = saturationRef.current; + if (!saturationElement) return; + setIsDragging("saturation"); + const rect = saturationElement.getBoundingClientRect(); + const x = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width)); + const y = Math.max(0, Math.min(1, (event.clientY - rect.top) / rect.height)); + const newHex = appendAlpha({ + rgbHex: hsvToHex({ h: displayHue, s: x, v: 1 - y }), + alpha, + }); + latestDragColorRef.current = newHex; + onChange?.(newHex); + }; + + const handleHueMouseDown = (event: React.MouseEvent) => { + event.preventDefault(); + const hueElement = hueRef.current; + if (!hueElement) return; + setIsDragging("hue"); + const rect = hueElement.getBoundingClientRect(); + const x = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width)); + const newH = x * 360; + setInternalHue(newH); + if (s > 0) { + const newHex = appendAlpha({ + rgbHex: hsvToHex({ h: newH, s, v }), + alpha, + }); + latestDragColorRef.current = newHex; + onChange?.(newHex); + } + }; + + const handleOpacityMouseDown = (event: React.MouseEvent) => { + event.preventDefault(); + const opacityElement = opacityRef.current; + if (!opacityElement) return; + setIsDragging("opacity"); + const rect = opacityElement.getBoundingClientRect(); + const x = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width)); + const newHex = appendAlpha({ rgbHex: rgbValue, alpha: x }); + latestDragColorRef.current = newHex; + onChange?.(newHex); + }; + + const handleInputChange = (event: React.ChangeEvent) => { + setInputValue( + colorFormat === "hex" + ? event.target.value.replace("#", "") + : event.target.value, + ); + }; + + const commitInputValue = () => { + const parsed = parseColorInput({ input: inputValue, format: colorFormat }); + if (parsed) { + const nextHex = appendAlpha({ rgbHex: parsed, alpha }); + onChange?.(nextHex); + onChangeEnd?.(nextHex); + return; + } + + const extracted = extractColorFromText({ text: inputValue }); + if (extracted) { + const hasExplicitAlpha = extracted.length > 6; + const finalHex = hasExplicitAlpha + ? extracted + : appendAlpha({ rgbHex: extracted, alpha }); + onChange?.(finalHex); + onChangeEnd?.(finalHex); + } + }; + + const handleInputBlur = () => commitInputValue(); + + const handleInputKeyDown = (event: React.KeyboardEvent) => { + if (event.key === "Enter") { + commitInputValue(); + event.currentTarget.blur(); + } + }; + + const handlePaste = (event: React.ClipboardEvent) => { + const pastedText = event.clipboardData.getData("text"); + const extractedHex = extractColorFromText({ text: pastedText }); + if (!extractedHex) return; + + event.preventDefault(); + const hasExplicitAlpha = extractedHex.length > 6; + const finalHex = hasExplicitAlpha + ? extractedHex + : appendAlpha({ rgbHex: extractedHex, alpha }); + onChange?.(finalHex); + onChangeEnd?.(finalHex); + }; + + const saturationStyle = { + background: `linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, hsl(${displayHue}, 100%, 50%))`, + }; + + const hueStyle = { + background: + "linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%)", + }; + + return ( + { + event.preventDefault(); + }} + onCloseAutoFocus={(event) => { + event.preventDefault(); + }} + onInteractOutside={(event) => { + if (isDragging) event.preventDefault(); + }} + > +
+ +
+ {isEyeDropperSupported && ( + + )} + + + +
+
+
+ + + + + + +
+ + + +
+
+
+ ); +} + +interface ColorPickerProps { + value?: string; + onChange?: (value: string) => void; + onChangeEnd?: (value: string) => void; + className?: string; + contentSide?: ComponentProps["side"]; + contentAlign?: ComponentProps["align"]; +} + +const ColorPicker = forwardRef( + ( + { + className, + value = "FFFFFF", + onChange, + onChangeEnd, + contentSide, + contentAlign, + ...props + }, + ref, + ) => { + const { alpha } = parseHexAlpha({ hex: value }); + + const [inputValue, setInputValue] = useState(value); + + useEffect(() => { + setInputValue(value); + }, [value]); + + const commitInputValue = (raw: string) => { + const input = raw.replace("#", ""); + const parsed = parseColorInput({ input, format: "hex" }); + if (parsed) { + const nextHex = appendAlpha({ rgbHex: parsed, alpha }); + onChange?.(nextHex); + onChangeEnd?.(nextHex); + return; + } + const extracted = extractColorFromText({ text: input }); + if (extracted) { + const hasExplicitAlpha = extracted.length > 6; + const finalHex = hasExplicitAlpha + ? extracted + : appendAlpha({ rgbHex: extracted, alpha }); + onChange?.(finalHex); + onChangeEnd?.(finalHex); + } + }; + + const handleInputChange = (event: React.ChangeEvent) => { + setInputValue(event.target.value.replace("#", "")); + }; + + const handleInputBlur = () => commitInputValue(inputValue); + + const handleInputKeyDown = ( + event: React.KeyboardEvent, + ) => { + if (event.key === "Enter") { + commitInputValue(inputValue); + event.currentTarget.blur(); + } + }; + + const handlePaste = (event: React.ClipboardEvent) => { + const pastedText = event.clipboardData.getData("text"); + const extractedHex = extractColorFromText({ text: pastedText }); + if (!extractedHex) return; + event.preventDefault(); + const hasExplicitAlpha = extractedHex.length > 6; + const finalHex = hasExplicitAlpha + ? extractedHex + : appendAlpha({ rgbHex: extractedHex, alpha }); + onChange?.(finalHex); + onChangeEnd?.(finalHex); + }; + + return ( + +
+ + + +
+ +
+
+ +
+ ); + }, +); +ColorPicker.displayName = "ColorPicker"; + +const ColorCircle = ({ + size, + position, + color, +}: { + size: "sm" | "md"; + position: { left: string; top: string }; + color?: string; +}) => ( +
+); + +export { ColorPicker, ColorPickerContent }; -- cgit v1.3.1