From f9170dcf09571b617bce9895f9c72efda8544b4c Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Wed, 27 Aug 2025 17:23:16 +0200 Subject: style: re-design colors in text properties panel --- .../editor/properties-panel/text-properties.tsx | 101 ++++--- apps/web/src/components/ui/color-picker.tsx | 325 +++++++++++++++++++++ apps/web/src/lib/utils.ts | 4 + 3 files changed, 393 insertions(+), 37 deletions(-) create mode 100644 apps/web/src/components/ui/color-picker.tsx (limited to 'apps/web') diff --git a/apps/web/src/components/editor/properties-panel/text-properties.tsx b/apps/web/src/components/editor/properties-panel/text-properties.tsx index 5f0e3706..deaee157 100644 --- a/apps/web/src/components/editor/properties-panel/text-properties.tsx +++ b/apps/web/src/components/editor/properties-panel/text-properties.tsx @@ -6,7 +6,6 @@ import { useTimelineStore } from "@/stores/timeline-store"; import { Slider } from "@/components/ui/slider"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; -import { Switch } from "@/components/ui/switch"; import { useState, useRef } from "react"; import { PanelBaseView } from "@/components/editor/panel-base-view"; import { @@ -19,6 +18,14 @@ import { PropertyItemLabel, PropertyItemValue, } from "./property-item"; +import { ColorPicker } from "@/components/ui/color-picker"; +import { cn, uppercase } from "@/lib/utils"; +import { Grid2x2 } from "lucide-react"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; export function TextProperties({ element, @@ -29,7 +36,7 @@ export function TextProperties({ }) { const { updateTextElement } = useTimelineStore(); const { activeTab, setActiveTab } = useTextPropertiesStore(); - + const containerRef = useRef(null); // Local state for input values to allow temporary empty/invalid states const [fontSizeInput, setFontSizeInput] = useState( element.fontSize.toString() @@ -118,6 +125,7 @@ export function TextProperties({ onValueChange={(v) => { if (isTextPropertiesTab(v)) setActiveTab(v); }} + ref={containerRef} tabs={TEXT_PROPERTIES_TABS.map((t) => ({ value: t.value, label: t.label, @@ -250,7 +258,7 @@ export function TextProperties({ max={300} onChange={(e) => handleFontSizeChange(e.target.value)} onBlur={handleFontSizeBlur} - className="w-12 px-2 !text-xs h-7 rounded-sm text-center + className="w-12 px-2 !text-xs h-7 rounded-sm text-center bg-panel-accent [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" @@ -261,14 +269,16 @@ export function TextProperties({ Color - { - const color = e.target.value; - updateTextElement(trackId, element.id, { color }); + { + updateTextElement(trackId, element.id, { + color: `#${color}`, + }); }} - className="w-full cursor-pointer rounded-full" + containerRef={containerRef} /> @@ -296,7 +306,7 @@ export function TextProperties({ max={100} onChange={(e) => handleOpacityChange(e.target.value)} onBlur={handleOpacityBlur} - className="w-12 !text-xs h-7 rounded-sm text-center + className="w-12 !text-xs h-7 rounded-sm text-center bg-panel-accent [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" @@ -305,34 +315,51 @@ export function TextProperties({ -
- Background -
- Background + +
+ handleColorChange(`#${color}`)} + containerRef={containerRef} + className={ + element.backgroundColor === "transparent" + ? "opacity-50 pointer-events-none" + : "" + } /> - + + + + + + Transparent background +
-
- - handleColorChange(e.target.value)} - className="w-full cursor-pointer rounded-full" - disabled={element.backgroundColor === "transparent"} - />
diff --git a/apps/web/src/components/ui/color-picker.tsx b/apps/web/src/components/ui/color-picker.tsx new file mode 100644 index 00000000..6a309d5a --- /dev/null +++ b/apps/web/src/components/ui/color-picker.tsx @@ -0,0 +1,325 @@ +import * as React from "react"; +import { createPortal } from "react-dom"; +import { cn } from "../../lib/utils"; +import { Input } from "./input"; + +interface ColorPickerProps { + value?: string; + onChange?: (value: string) => void; + className?: string; + containerRef?: React.RefObject; +} + +const hexToHsv = (hex: string) => { + const r = parseInt(hex.slice(0, 2), 16) / 255; + const g = parseInt(hex.slice(2, 4), 16) / 255; + const b = parseInt(hex.slice(4, 6), 16) / 255; + + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + const diff = max - min; + + let h = 0; + let s = max === 0 ? 0 : diff / max; + let v = max; + + if (diff !== 0) { + switch (max) { + case r: + h = ((g - b) / diff) % 6; + break; + case g: + h = (b - r) / diff + 2; + break; + case b: + h = (r - g) / diff + 4; + break; + } + } + + return [h * 60, s, v]; +}; + +const hsvToHex = (h: number, s: number, v: number) => { + const c = v * s; + const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); + const m = v - c; + + let r = 0, + g = 0, + b = 0; + + if (h >= 0 && h < 60) { + r = c; + g = x; + b = 0; + } else if (h >= 60 && h < 120) { + r = x; + g = c; + b = 0; + } else if (h >= 120 && h < 180) { + r = 0; + g = c; + b = x; + } else if (h >= 180 && h < 240) { + r = 0; + g = x; + b = c; + } else if (h >= 240 && h < 300) { + r = x; + g = 0; + b = c; + } else if (h >= 300 && 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); + + return [r, g, b].map((x) => x.toString(16).padStart(2, "0")).join(""); +}; + +const ColorPicker = React.forwardRef( + ({ className, value = "FFFFFF", onChange, containerRef, ...props }, ref) => { + const [isOpen, setIsOpen] = React.useState(false); + const [isDragging, setIsDragging] = React.useState< + "saturation" | "hue" | null + >(null); + const [pickerPosition, setPickerPosition] = React.useState({ + right: 0, + bottom: 0, + }); + const pickerRef = React.useRef(null); + const saturationRef = React.useRef(null); + const hueRef = React.useRef(null); + const triggerRef = React.useRef(null); + + const [h, s, v] = hexToHsv(value); + + React.useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + pickerRef.current && + !pickerRef.current.contains(event.target as Node) + ) { + setIsOpen(false); + } + }; + + if (isOpen) { + document.addEventListener("mousedown", handleClickOutside); + return () => + document.removeEventListener("mousedown", handleClickOutside); + } + }, [isOpen]); + + React.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 newS = x; + const newV = 1 - y; + const newHex = hsvToHex(h, newS, newV); + 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; + const newHex = hsvToHex(newH, s, v); + onChange?.(newHex); + } + }; + + const handleMouseUp = () => { + setIsDragging(null); + }; + + if (isDragging) { + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + } + }, [isDragging, h, s, v, onChange]); + + const handleSaturationMouseDown = (e: React.MouseEvent) => { + e.preventDefault(); + setIsDragging("saturation"); + + 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 newS = x; + const newV = 1 - y; + const newHex = hsvToHex(h, newS, newV); + onChange?.(newHex); + }; + + const handleHueMouseDown = (e: React.MouseEvent) => { + setIsDragging("hue"); + + const rect = hueRef.current!.getBoundingClientRect(); + const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); + const newH = x * 360; + const newHex = hsvToHex(newH, s, v); + onChange?.(newHex); + }; + + const [inputValue, setInputValue] = React.useState(value); + + React.useEffect(() => { + setInputValue(value); + }, [value]); + + const handleInputChange = (e: React.ChangeEvent) => { + const hex = e.target.value.replace("#", ""); + setInputValue(hex); + }; + + const handleInputBlur = () => { + onChange?.(inputValue); + }; + + const handleInputKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + onChange?.(inputValue); + e.currentTarget.blur(); + } + }; + + const saturationStyle = { + background: `linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, hsl(${h}, 100%, 50%))`, + }; + + const hueStyle = { + background: + "linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%)", + }; + + return ( +
+
+
{ + if (!isOpen && triggerRef.current && containerRef?.current) { + const containerRect = + containerRef.current.getBoundingClientRect(); + + setPickerPosition({ + right: window.innerWidth - containerRect.left - 8, + bottom: window.innerHeight - containerRect.bottom, + }); + } + setIsOpen(!isOpen); + }} + /> +
+ +
+
+ + {isOpen && + createPortal( +
+
+ +
+ +
+ +
+
, + document.body + )} +
+ ); + } +); +ColorPicker.displayName = "ColorPicker"; + +const ColorCircle = ({ + size, + position, + color, +}: { + size: "sm" | "md"; + position: { left: string; top: string }; + color: string; +}) => ( +
+); + +export { ColorPicker }; diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts index 8d2ee03a..42159b2e 100644 --- a/apps/web/src/lib/utils.ts +++ b/apps/web/src/lib/utils.ts @@ -7,6 +7,10 @@ export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } +export function uppercase(str: string) { + return str.toUpperCase(); +} + /** * Generates a UUID v4 string * Uses crypto.randomUUID() if available, otherwise falls back to a custom implementation -- cgit v1.3.1