import { useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { copyTextToClipboard } from '../../../../utils/clipboard'; const COPY_SUCCESS_TIMEOUT_MS = 2000; type CopyFormat = 'text' | 'markdown'; type CopyFormatOption = { format: CopyFormat; label: string; }; // Converts markdown into readable plain text for "Copy as text". const convertMarkdownToPlainText = (markdown: string): string => { let plainText = markdown.replace(/\r\n/g, '\n'); const codeBlocks: string[] = []; plainText = plainText.replace(/```[\w-]*\n([\s\S]*?)```/g, (_match, code: string) => { const placeholder = `@@CODEBLOCK${codeBlocks.length}@@`; codeBlocks.push(code.replace(/\n$/, '')); return placeholder; }); plainText = plainText.replace(/`([^`]+)`/g, '$1'); plainText = plainText.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '$1'); plainText = plainText.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1'); plainText = plainText.replace(/^>\s?/gm, ''); plainText = plainText.replace(/^#{1,6}\s+/gm, ''); plainText = plainText.replace(/^[-*+]\s+/gm, ''); plainText = plainText.replace(/^\d+\.\s+/gm, ''); plainText = plainText.replace(/(\*\*|__)(.*?)\1/g, '$2'); plainText = plainText.replace(/(\*|_)(.*?)\1/g, '$2'); plainText = plainText.replace(/~~(.*?)~~/g, '$1'); plainText = plainText.replace(/<\/?[^>]+(>|$)/g, ''); plainText = plainText.replace(/\n{3,}/g, '\n\n'); plainText = plainText.replace(/@@CODEBLOCK(\d+)@@/g, (_match, index: string) => codeBlocks[Number(index)] ?? ''); return plainText.trim(); }; const MessageCopyControl = ({ content, messageType, }: { content: string; messageType: 'user' | 'assistant'; }) => { const { t } = useTranslation('chat'); const canSelectCopyFormat = messageType === 'assistant'; const defaultFormat: CopyFormat = canSelectCopyFormat ? 'markdown' : 'text'; const [selectedFormat, setSelectedFormat] = useState(defaultFormat); const [copied, setCopied] = useState(false); const [isDropdownOpen, setIsDropdownOpen] = useState(false); const dropdownRef = useRef(null); const copyFeedbackTimerRef = useRef | null>(null); const copyFormatOptions: CopyFormatOption[] = useMemo( () => [ { format: 'markdown', label: t('copyMessage.copyAsMarkdown', { defaultValue: 'Copy as markdown' }), }, { format: 'text', label: t('copyMessage.copyAsText', { defaultValue: 'Copy as text' }), }, ], [t] ); const selectedFormatTag = selectedFormat === 'markdown' ? t('copyMessage.markdownShort', { defaultValue: 'MD' }) : t('copyMessage.textShort', { defaultValue: 'TXT' }); const copyPayload = useMemo(() => { if (selectedFormat === 'markdown') { return content; } return convertMarkdownToPlainText(content); }, [content, selectedFormat]); useEffect(() => { setSelectedFormat(defaultFormat); setIsDropdownOpen(false); }, [defaultFormat]); useEffect(() => { // Close the dropdown when clicking anywhere outside this control. const closeOnOutsideClick = (event: MouseEvent) => { if (!isDropdownOpen) return; const target = event.target as Node; if (dropdownRef.current && !dropdownRef.current.contains(target)) { setIsDropdownOpen(false); } }; window.addEventListener('mousedown', closeOnOutsideClick); return () => { window.removeEventListener('mousedown', closeOnOutsideClick); }; }, [isDropdownOpen]); useEffect(() => { return () => { if (copyFeedbackTimerRef.current) { clearTimeout(copyFeedbackTimerRef.current); } }; }, []); const handleCopyClick = async () => { if (!copyPayload.trim()) return; const didCopy = await copyTextToClipboard(copyPayload); if (!didCopy) return; setCopied(true); if (copyFeedbackTimerRef.current) { clearTimeout(copyFeedbackTimerRef.current); } copyFeedbackTimerRef.current = setTimeout(() => { setCopied(false); }, COPY_SUCCESS_TIMEOUT_MS); }; const handleFormatChange = (format: CopyFormat) => { setSelectedFormat(format); setIsDropdownOpen(false); }; const toneClass = messageType === 'user' ? 'text-blue-100 hover:text-white' : 'text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300'; const copyTitle = copied ? t('copyMessage.copied') : t('copyMessage.copy'); const rootClassName = canSelectCopyFormat ? 'relative flex min-w-0 flex-1 items-center gap-0.5 sm:min-w-max sm:flex-none sm:w-auto' : 'relative flex items-center gap-0.5'; return (
{canSelectCopyFormat && ( <> {isDropdownOpen && (
{copyFormatOptions.map((option) => { const isSelected = option.format === selectedFormat; return ( ); })}
)} )}
); }; export default MessageCopyControl;