summaryrefslogtreecommitdiff
path: root/apps
diff options
context:
space:
mode:
Diffstat (limited to 'apps')
-rw-r--r--apps/web/src/components/editor/properties-panel/text-properties.tsx101
-rw-r--r--apps/web/src/components/ui/color-picker.tsx325
-rw-r--r--apps/web/src/lib/utils.ts4
3 files changed, 393 insertions, 37 deletions
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<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}
+ <PropertyItemLabel>Background</PropertyItemLabel>
+ <PropertyItemValue>
+ <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"
+ : ""
+ }
/>
- <label
- htmlFor="transparent-bg-toggle"
- className="text-sm font-medium"
- >
- Transparent
- </label>
+
+ <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>
- </div>
- <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"}
- />
</PropertyItemValue>
</PropertyItem>
</div>
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<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 };
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