Spaces:
Running
Running
| // 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<HTMLButtonElement>(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 ( | |
| <div className="flex items-center justify-between px-2 py-1"> | |
| <span className="text-[12px] text-fg-muted">{label}</span> | |
| <div className="flex items-center gap-1.5"> | |
| <label title={`${label} color`} | |
| className="relative grid h-6 w-6 cursor-pointer place-items-center overflow-hidden rounded-md ring-1 ring-inset ring-fg/20" | |
| style={{ background: HEX6.test(cur) ? cur : "#000000" }}> | |
| <Pipette size={10} className="pointer-events-none drop-shadow" style={{ color: "#fff", mixBlendMode: "difference" }} /> | |
| <input type="color" value={HEX6.test(cur) ? cur : "#000000"} | |
| onInput={(e) => setColor(k, (e.target as HTMLInputElement).value)} | |
| onChange={(e) => setColor(k, e.target.value)} | |
| className="absolute inset-0 cursor-pointer opacity-0" /> | |
| </label> | |
| <button title="Reset to preset" onClick={() => setColor(k, null)} | |
| className={"grid h-5 w-5 place-items-center rounded text-fg-muted hover:bg-white/5 " + (overridden ? "opacity-100" : "opacity-0 pointer-events-none")}> | |
| <X size={11} /> | |
| </button> | |
| </div> | |
| </div> | |
| ); | |
| }; | |
| return ( | |
| <div className="relative flex items-center"> | |
| <button title="Toggle captions" onClick={() => setOn(!on)} | |
| className={"flex h-7 items-center gap-1 rounded px-1.5 text-[11px] font-semibold " + (on ? "text-primary" : "text-fg-muted hover:text-fg")}> | |
| <Captions size={15} /> {on ? "ON" : "OFF"} | |
| </button> | |
| <button ref={btnRef} data-caption-toggle title="Caption style" onClick={toggle} className="grid h-7 w-4 place-items-center rounded text-fg-muted hover:bg-white/5"><ChevronUp size={12} /></button> | |
| {open && createPortal( | |
| <> | |
| <div data-caption-menu style={{ position: "fixed", right: pos.right, bottom: pos.bottom }} className="z-[60] max-h-[28rem] w-72 overflow-y-auto rounded-lg border border-border bg-surface-raised p-2 shadow-xl"> | |
| {/* header + self-contained on/off */} | |
| <div className="flex items-center justify-between px-1 pb-2 pt-1"> | |
| <span className="text-[13px] font-semibold text-fg">Captions</span> | |
| <button onClick={() => setOn(!on)} | |
| className={"rounded-full px-2 py-0.5 text-[11px] font-semibold " + (on ? "bg-primary/15 text-primary" : "bg-white/5 text-fg-muted")}> | |
| {on ? "ON" : "OFF"} | |
| </button> | |
| </div> | |
| {/* style gallery — live "Aa" preview of the MERGED look (preset + overrides) */} | |
| <div className="eyebrow px-1 pb-1">Style</div> | |
| <div className="grid grid-cols-2 gap-1.5 px-0.5"> | |
| {CAPTION_STYLES.map((s) => { | |
| const m = resolveCaptionStyle(s, colors); | |
| const swatchBg = m.box?.color ?? (m.bg ? m.bg.color : "#0d0d0f"); | |
| return ( | |
| <button key={s.id} onClick={() => { setStyle(s.id); setOn(true); }} | |
| className={"flex flex-col items-center gap-1 rounded-md border border-border/60 p-2 hover:bg-primary/5 " + (s.id === styleId ? "ring-2 ring-primary" : "")}> | |
| <span className="grid h-9 w-full place-items-center rounded text-[15px] font-extrabold" | |
| style={{ | |
| fontFamily: m.fontFamily, fontWeight: m.fontWeight as number, | |
| color: m.activeColor, background: swatchBg, | |
| textTransform: m.uppercase ? "uppercase" : "none", | |
| WebkitTextStroke: m.stroke ? `${Math.min(2, m.stroke.width / 4)}px ${m.stroke.color}` : undefined, | |
| } as React.CSSProperties}>Aa</span> | |
| <span className="text-[10px] leading-tight text-fg-muted">{s.label}</span> | |
| </button> | |
| ); | |
| })} | |
| </div> | |
| {/* animation picker — "" chip means "use this style's signature anim" */} | |
| <div className="eyebrow px-1 pb-1 pt-3">Animation</div> | |
| <div className="flex flex-wrap gap-1 px-0.5"> | |
| <button onClick={() => setAnim("")} | |
| className={"rounded px-2 py-1 text-[11px] " + (!anim ? "bg-primary/15 text-primary" : "text-fg-muted hover:bg-white/5")}> | |
| Auto | |
| </button> | |
| {CAPTION_ANIMS.map((a) => ( | |
| <button key={a.id} onClick={() => setAnim(a.id)} | |
| className={"rounded px-2 py-1 text-[11px] " + (anim === a.id ? "bg-primary/15 text-primary" : "text-fg-muted hover:bg-white/5")}> | |
| {a.label} | |
| </button> | |
| ))} | |
| </div> | |
| {!anim && ( | |
| <div className="px-1 pt-1 text-[10px] text-fg-muted">Using “{base.label}” default ({effectiveAnim}).</div> | |
| )} | |
| {/* color overrides — ride on top of the chosen preset */} | |
| <div className="flex items-center justify-between px-1 pb-1 pt-3"> | |
| <span className="eyebrow">Colors</span> | |
| {hasOverrides && ( | |
| <button onClick={resetColors} className="text-[10px] text-fg-muted hover:text-fg">Reset</button> | |
| )} | |
| </div> | |
| <ColorRow label="Active word" k="active" /> | |
| <ColorRow label="Base text" k="base" /> | |
| <ColorRow label="Highlight" k="highlight" /> | |
| </div> | |
| </>, | |
| document.body, | |
| )} | |
| </div> | |
| ); | |
| } | |