eldouma-graphics / src /components /CaptionMenu.tsx
Moeeldouma's picture
Deploy Eldouma Graphics — Docker Space, bring-your-own-key demo
39d98a0 verified
Raw
History Blame Contribute Delete
8.5 kB
// 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>
);
}