style: re-design colors in text properties panel

This commit is contained in:
Maze Winther 2025-08-27 17:23:16 +02:00
parent ebe1b38bdf
commit f9170dcf09
4 changed files with 401 additions and 39 deletions

View File

@ -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<HTMLDivElement>(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({
<PropertyItem direction="column">
<PropertyItemLabel>Color</PropertyItemLabel>
<PropertyItemValue>
<Input
type="color"
value={element.color || "#ffffff"}
onChange={(e) => {
const color = e.target.value;
updateTextElement(trackId, element.id, { color });
<ColorPicker
value={uppercase(
(element.color || "FFFFFF").replace("#", "")
)}
onChange={(color) => {
updateTextElement(trackId, element.id, {
color: `#${color}`,
});
}}
className="w-full cursor-pointer rounded-full"
containerRef={containerRef}
/>
</PropertyItemValue>
</PropertyItem>
@ -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({
</PropertyItemValue>
</PropertyItem>
<PropertyItem direction="column">
<div className="flex items-center justify-between">
<PropertyItemLabel>Background</PropertyItemLabel>
<div className="flex items-center space-x-2">
<Switch
id="transparent-bg-toggle"
checked={element.backgroundColor === "transparent"}
onCheckedChange={handleTransparentToggle}
/>
<label
htmlFor="transparent-bg-toggle"
className="text-sm font-medium"
>
Transparent
</label>
</div>
</div>
<PropertyItemLabel>Background</PropertyItemLabel>
<PropertyItemValue>
<Input
type="color"
value={
element.backgroundColor === "transparent"
? lastSelectedColor.current
: element.backgroundColor || "#000000"
}
onChange={(e) => handleColorChange(e.target.value)}
className="w-full cursor-pointer rounded-full"
disabled={element.backgroundColor === "transparent"}
/>
<div className="flex items-center gap-2">
<ColorPicker
value={uppercase(
element.backgroundColor === "transparent"
? lastSelectedColor.current.replace("#", "")
: (element.backgroundColor || "#000000").replace(
"#",
""
)
)}
onChange={(color) => handleColorChange(`#${color}`)}
containerRef={containerRef}
className={
element.backgroundColor === "transparent"
? "opacity-50 pointer-events-none"
: ""
}
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() =>
handleTransparentToggle(
element.backgroundColor !== "transparent"
)
}
className="size-9 rounded-full bg-panel-accent p-0 overflow-hidden"
>
<Grid2x2
className={cn(
"text-foreground",
element.backgroundColor === "transparent" &&
"text-primary"
)}
/>
</Button>
</TooltipTrigger>
<TooltipContent>Transparent background</TooltipContent>
</Tooltip>
</div>
</PropertyItemValue>
</PropertyItem>
</div>

View File

@ -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<HTMLDivElement>;
}
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<HTMLDivElement, ColorPickerProps>(
({ 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<HTMLDivElement>(null);
const saturationRef = React.useRef<HTMLDivElement>(null);
const hueRef = React.useRef<HTMLDivElement>(null);
const triggerRef = React.useRef<HTMLDivElement>(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<HTMLInputElement>) => {
const hex = e.target.value.replace("#", "");
setInputValue(hex);
};
const handleInputBlur = () => {
onChange?.(inputValue);
};
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
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 (
<div className="relative">
<div
ref={ref}
className={cn(
"bg-panel-accent h-9 rounded-full flex items-center gap-2 px-[0.45rem]",
className
)}
{...props}
>
<div
ref={triggerRef}
className="size-6 rounded-full cursor-pointer hover:ring-2 hover:ring-white/20 transition-all"
style={{ backgroundColor: `#${value}` }}
onClick={() => {
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);
}}
/>
<div className="flex-1 flex items-center">
<Input
className="bg-transparent p-0 !ring-0 !ring-offset-0 !border-0 "
containerClassName="w-full"
value={inputValue}
onChange={handleInputChange}
onBlur={handleInputBlur}
onKeyDown={handleInputKeyDown}
/>
</div>
</div>
{isOpen &&
createPortal(
<div
ref={pickerRef}
className="fixed z-50 p-4 bg-popover border border-border rounded-lg shadow-lg select-none"
style={{
right: pickerPosition.right,
bottom: pickerPosition.bottom,
}}
>
<div
ref={saturationRef}
className="relative w-48 h-32 cursor-crosshair mb-3"
style={saturationStyle}
onMouseDown={handleSaturationMouseDown}
>
<ColorCircle
size="sm"
position={{ left: `${s * 100}%`, top: `${(1 - v) * 100}%` }}
color={`#${value}`}
/>
</div>
<div
ref={hueRef}
className="relative w-48 h-4 rounded-lg cursor-pointer"
style={hueStyle}
onMouseDown={handleHueMouseDown}
>
<ColorCircle
size="md"
position={{ left: `${(h / 360) * 100}%`, top: "50%" }}
color={`#${value}`}
/>
</div>
</div>,
document.body
)}
</div>
);
}
);
ColorPicker.displayName = "ColorPicker";
const ColorCircle = ({
size,
position,
color,
}: {
size: "sm" | "md";
position: { left: string; top: string };
color: string;
}) => (
<div
className={`absolute border-3 border-accent rounded-full shadow-lg pointer-events-none ${
size === "sm" ? "w-3 h-3" : "w-4 h-4"
}`}
style={{
left: position.left,
top: position.top,
transform: "translate(-50%, -50%)",
backgroundColor: color,
}}
/>
);
export { ColorPicker };

View File

@ -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

View File

@ -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=="],
}
}