import { CheckIcon, CopyIcon } from "lucide-react"; import { memo, useEffect, useRef, useState, type MouseEvent, type ReactNode } from "react"; import { cn } from "../lib/cn"; import { copyTextToClipboard } from "../lib/clipboard"; import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip"; import { useOpenGeniText } from "../messages/runtime"; const COPIED_MS = 1600; export type CopyButtonProps = { /** Plain text to copy, or a lazy getter (tables / late DOM). */ text: string | (() => string); /** Accessible name + tooltip while idle. */ label?: string | undefined; className?: string | undefined; /** * `always` — visible control. * `group-hover` — parent must use `group/copy`; fades in on hover/focus-visible. */ reveal?: "always" | "group-hover" | undefined; }; /** * Ghost icon copy control — no chrome box, no "Copy code" label. * Copy → check flash. Hover-reveal via `group/copy` on a parent. * Keep unchanged controls out of parent message rerenders: mounting history * already has to commit each new tooltip, without redoing the retained suffix. */ export const CopyButton = memo(function CopyButton({ text, label: labelProp, className, reveal = "group-hover", }: CopyButtonProps) { const t = useOpenGeniText(); const label = labelProp === undefined ? t("copyButton.copy") : labelProp; const [copied, setCopied] = useState(false); const [tipOpen, setTipOpen] = useState(false); const timerRef = useRef | null>(null); useEffect(() => { return () => { if (timerRef.current !== null) { clearTimeout(timerRef.current); } }; }, []); const onClick = async (event: MouseEvent) => { event.preventDefault(); event.stopPropagation(); // Capture before any await: React nulls `currentTarget` after the // synchronous handler turn. Blurring via the stale target throws and used // to skip the copied-reset timer, leaving the tip stuck on "Copied". const button = event.currentTarget; const pointerActivated = event.detail > 0; const value = typeof text === "function" ? text() : text; const ok = await copyTextToClipboard(value); if (!ok) { return; } setCopied(true); setTipOpen(true); if (timerRef.current !== null) { clearTimeout(timerRef.current); } // Always arm the reset before blur — a throw here must not leave the // forced "Copied" tip open forever. timerRef.current = setTimeout(() => { timerRef.current = null; setCopied(false); setTipOpen(false); }, COPIED_MS); // Pointer activation focuses the button and leaves `group-focus-within` // stuck after the cursor leaves — chrome never "unhovers". Keyboard // activation (`detail === 0`) keeps focus for a11y. if (pointerActivated) { button.blur(); } }; const tip = copied ? t("copyButton.copied") : label; return ( { if (!copied) { setTipOpen(next); } }} > {tip} ); }); /** Hover-reveal chrome around a message / turn so {@link CopyButton} can fade in. */ export function CopyHoverFrame({ children, className, copyText, label, align = "end", trailing, }: { children: ReactNode; className?: string | undefined; copyText: string; label: string; align?: "start" | "end" | undefined; /** Optional footer meta (e.g. sent/finished clock) — immediately after the copy control. */ trailing?: ReactNode | undefined; }) { if (copyText.trim().length === 0) { return
{children}
; } return (
{children} {/* Sit under the body — top-right overlay collided with the first line. */}
{trailing}
); }