// src/components/CaptionMenu.tsx — FINAL (gallery + animation picker + color overrides). import { useState, useRef, useEffect } from "react"; import { createPortal } from "react-dom"; import { Captions, ChevronUp, Pipette, X } from "lucide-react"; import { useStore } from "../store"; import { CAPTION_STYLES, CAPTION_ANIMS, captionStyleById, resolveCaptionStyle } from "../lib/captions"; const HEX6 = /^#?[0-9a-fA-F]{6}$/; const FALLBACK_HIGHLIGHT = "#16a34a"; type ColorKey = "active" | "base" | "highlight"; export function CaptionMenu() { const on = useStore((s) => s.captionsOn); const styleId = useStore((s) => s.captionStyle); const colors = useStore((s) => s.captionColors); const anim = useStore((s) => s.captionAnim); const setOn = useStore((s) => s.setCaptionsOn); const setStyle = useStore((s) => s.setCaptionStyle); const setColor = useStore((s) => s.setCaptionColor); const setAnim = useStore((s) => s.setCaptionAnim); const resetColors = useStore((s) => s.resetCaptionColors); const [open, setOpen] = useState(false); const btnRef = useRef(null); // the panel renders in a portal (so the timeline's overflow can't clip it); anchor it just above the trigger const [pos, setPos] = useState<{ right: number; bottom: number }>({ right: 16, bottom: 56 }); const toggle = () => { const r = btnRef.current?.getBoundingClientRect(); if (r) setPos({ right: Math.max(8, Math.round(window.innerWidth - r.right)), bottom: Math.round(window.innerHeight - r.top + 6) }); setOpen((o) => !o); }; // close on a real outside click — NOT a full-screen overlay (that collapsed the menu the moment the // native colour picker was dismissed, so you could only ever land one click before it vanished). // The OS colour dialog is a separate window, so dragging in it never dispatches a page mousedown. useEffect(() => { if (!open) return; const onDown = (e: MouseEvent) => { const t = e.target as HTMLElement; if (t.closest("[data-caption-menu]") || t.closest("[data-caption-toggle]")) return; setOpen(false); }; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setOpen(false); }; window.addEventListener("mousedown", onDown); window.addEventListener("keydown", onKey); return () => { window.removeEventListener("mousedown", onDown); window.removeEventListener("keydown", onKey); }; }, [open]); const base = captionStyleById(styleId || "tiktok-pop"); const effectiveAnim = anim || base.anim || "none"; const hasOverrides = Object.keys(colors).length > 0; // a color row reading the merged "current value" back from the same resolver the renderer uses const ColorRow = ({ label, k }: { label: string; k: ColorKey }) => { const presetVal = k === "active" ? base.activeColor : k === "base" ? base.color : base.box?.color ?? FALLBACK_HIGHLIGHT; const cur = colors[k] ?? presetVal; const overridden = colors[k] != null; return (
{label}
); }; return (
{open && createPortal( <>
{/* header + self-contained on/off */}
Captions
{/* style gallery — live "Aa" preview of the MERGED look (preset + overrides) */}
Style
{CAPTION_STYLES.map((s) => { const m = resolveCaptionStyle(s, colors); const swatchBg = m.box?.color ?? (m.bg ? m.bg.color : "#0d0d0f"); return ( ); })}
{/* animation picker — "" chip means "use this style's signature anim" */}
Animation
{CAPTION_ANIMS.map((a) => ( ))}
{!anim && (
Using “{base.label}” default ({effectiveAnim}).
)} {/* color overrides — ride on top of the chosen preset */}
Colors {hasOverrides && ( )}
, document.body, )}
); }