summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-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
-rw-r--r--bun.lock8
4 files changed, 400 insertions, 38 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
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=="],
}
}