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(e.target.value)} - className="w-full cursor-pointer rounded-full" - disabled={element.backgroundColor === "transparent"} - /> +
+ handleColorChange(`#${color}`)} + containerRef={containerRef} + className={ + element.backgroundColor === "transparent" + ? "opacity-50 pointer-events-none" + : "" + } + /> + + + + + + Transparent background + +
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 diff --git a/bun.lock b/bun.lock index 95f7c874..659110a6 100644 --- a/bun.lock +++ b/bun.lock @@ -533,7 +533,7 @@ "@tailwindcss/typography": ["@tailwindcss/typography@0.5.16", "", { "dependencies": { "lodash.castarray": "^4.4.0", "lodash.isplainobject": "^4.0.6", "lodash.merge": "^4.6.2", "postcss-selector-parser": "6.0.10" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, "sha512-0wDLwCVF5V3x3b1SGXPCDcdsbDHMBe+lkFzBRaHeLvNi+nrrnZ1lA18u+OTWO8iSWU2GxUOCvlXtDuqftc1oiA=="], - "@types/bun": ["@types/bun@1.2.19", "", { "dependencies": { "bun-types": "1.2.19" } }, "sha512-d9ZCmrH3CJ2uYKXQIUuZ/pUnTqIvLDS0SK7pFmbx8ma+ziH/FRMoAq5bYpRG7y+w1gl+HgyNZbtqgMq4W4e2Lg=="], + "@types/bun": ["@types/bun@1.2.21", "", { "dependencies": { "bun-types": "1.2.21" } }, "sha512-NiDnvEqmbfQ6dmZ3EeUO577s4P5bf4HCTXtI6trMc6f6RzirY5IrF3aIookuSpyslFzrnvv2lmEWv5HyC1X79A=="], "@types/chai": ["@types/chai@5.2.2", "", { "dependencies": { "@types/deep-eql": "*" } }, "sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg=="], @@ -1257,6 +1257,8 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@types/bun/bun-types": ["bun-types@1.2.21", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-sa2Tj77Ijc/NTLS0/Odjq/qngmEPZfbfnOERi0KRUYhT9R8M4VBioWVmMWE5GrYbKMc+5lVybXygLdibHaqVqw=="], + "better-auth/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "motion/framer-motion": ["framer-motion@12.23.6", "", { "dependencies": { "motion-dom": "^12.23.6", "motion-utils": "^12.23.6", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-dsJ389QImVE3lQvM8Mnk99/j8tiZDM/7706PCqvkQ8sSCnpmWxsgX+g0lj7r5OBVL0U36pIecCTBoIWcM2RuKw=="], @@ -1323,6 +1325,8 @@ "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ=="], + "@types/bun/bun-types/@types/node": ["@types/node@24.2.1", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ=="], + "motion/framer-motion/motion-dom": ["motion-dom@12.23.6", "", { "dependencies": { "motion-utils": "^12.23.6" } }, "sha512-G2w6Nw7ZOVSzcQmsdLc0doMe64O/Sbuc2bVAbgMz6oP/6/pRStKRiVRV4bQfHp5AHYAKEGhEdVHTM+R3FDgi5w=="], "motion/framer-motion/motion-utils": ["motion-utils@12.23.6", "", {}, "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ=="], @@ -1351,6 +1355,8 @@ "opencut/next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], + "@types/bun/bun-types/@types/node/undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="], + "opencut/next/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], } }