Spaces:
Running
Running
| import { useEffect, useState, useMemo } from 'react'; | |
| import MarkdownRenderer from './MarkdownRenderer'; | |
| import { X, Copy, Check, Download, PanelRightClose, PanelRightOpen } from 'lucide-react'; | |
| import 'highlight.js/styles/github-dark.css'; | |
| interface FilePreviewModalProps { | |
| isOpen: boolean; | |
| onClose: () => void; | |
| content: string; | |
| } | |
| // Helper to generate IDs from text | |
| const slugify = (text: string) => { | |
| return text | |
| .toString() | |
| .toLowerCase() | |
| .trim() | |
| .replace(/\s+/g, '-') | |
| .replace(/[^\w\u4e00-\u9fa5-]+/g, '') | |
| .replace(/-+/g, '-'); | |
| }; | |
| export default function FilePreviewModal({ isOpen, onClose, content }: FilePreviewModalProps) { | |
| const [copied, setCopied] = useState(false); | |
| const [showToc, setShowToc] = useState(false); | |
| useEffect(() => { | |
| if (isOpen) { | |
| document.body.style.overflow = 'hidden'; | |
| } else { | |
| document.body.style.overflow = 'unset'; | |
| } | |
| return () => { | |
| document.body.style.overflow = 'unset'; | |
| }; | |
| }, [isOpen]); | |
| // Generate TOC data | |
| const toc = useMemo(() => { | |
| const lines = content.split('\n'); | |
| const headers: { level: number; text: string; id: string }[] = []; | |
| // Simple regex to match headers. Note: this won't handle code blocks correctly if they contain # | |
| // But for a simple TOC it's usually "good enough". | |
| // A more robust way would be to traverse the AST, but that requires more setup. | |
| let inCodeBlock = false; | |
| lines.forEach(line => { | |
| if (line.trim().startsWith('```')) { | |
| inCodeBlock = !inCodeBlock; | |
| return; | |
| } | |
| if (inCodeBlock) return; | |
| const match = line.match(/^(#{1,3})\s+(.+)$/); | |
| if (match) { | |
| const level = match[1].length; | |
| const text = match[2].trim(); | |
| const id = slugify(text); | |
| headers.push({ level, text, id }); | |
| } | |
| }); | |
| return headers; | |
| }, [content]); | |
| if (!isOpen) return null; | |
| const handleCopy = () => { | |
| navigator.clipboard.writeText(content); | |
| setCopied(true); | |
| setTimeout(() => setCopied(false), 2000); | |
| }; | |
| const handleDownload = () => { | |
| const blob = new Blob([content], { type: 'text/markdown' }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = 'content.md'; | |
| document.body.appendChild(a); | |
| a.click(); | |
| document.body.removeChild(a); | |
| URL.revokeObjectURL(url); | |
| }; | |
| const scrollToHeader = (id: string) => { | |
| const element = document.getElementById(id); | |
| if (element) { | |
| element.scrollIntoView({ behavior: 'smooth' }); | |
| } | |
| }; | |
| // Custom components for ReactMarkdown to add IDs - Removed as we switched to marked | |
| // In a full implementation we would configure marked slugger or use a custom renderer for headers | |
| // For now, TOC scrolling relies on ids that might not be present if we don't add them. | |
| // marked adds ids to headers by default (gfm: true). | |
| // We can verify this or use a simple post-processing/custom renderer if needed. | |
| // For this "change parser" request, let's stick to default marked behavior first. | |
| return ( | |
| <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4 animate-in fade-in duration-200"> | |
| <div | |
| className="bg-white dark:bg-gray-900 w-full max-w-6xl h-[90vh] rounded-2xl shadow-2xl flex flex-col overflow-hidden animate-in zoom-in-95 duration-200 border border-gray-200 dark:border-gray-800" | |
| onClick={(e) => e.stopPropagation()} | |
| > | |
| {/* Header */} | |
| <div className="flex items-center justify-between px-6 py-4 border-b border-gray-100 dark:border-gray-800 bg-white/50 dark:bg-gray-900/50 backdrop-blur shrink-0"> | |
| <div className="flex items-center gap-3"> | |
| <div className="p-2 bg-blue-50 dark:bg-blue-900/20 rounded-lg"> | |
| <svg className="w-5 h-5 text-blue-600 dark:text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | |
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /> | |
| </svg> | |
| </div> | |
| <div> | |
| <h3 className="font-medium text-gray-900 dark:text-gray-100">Markdown Preview</h3> | |
| <p className="text-xs text-gray-500 dark:text-gray-400">Read-only mode</p> | |
| </div> | |
| </div> | |
| <div className="flex items-center gap-2"> | |
| {/* TOC Toggle */} | |
| {toc.length > 0 && ( | |
| <button | |
| onClick={() => setShowToc(!showToc)} | |
| className={`p-2 rounded-lg transition-colors flex items-center gap-2 ${ | |
| showToc | |
| ? 'text-blue-600 bg-blue-50 dark:text-blue-400 dark:bg-blue-900/20' | |
| : 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800' | |
| }`} | |
| title={showToc ? "Hide Table of Contents" : "Show Table of Contents"} | |
| > | |
| {showToc ? <PanelRightClose size={18} /> : <PanelRightOpen size={18} />} | |
| </button> | |
| )} | |
| <div className="w-px h-6 bg-gray-200 dark:bg-gray-700 mx-1"></div> | |
| <button | |
| onClick={handleCopy} | |
| className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors flex items-center gap-2" | |
| title="Copy content" | |
| > | |
| {copied ? <Check size={18} className="text-green-500" /> : <Copy size={18} />} | |
| </button> | |
| <button | |
| onClick={handleDownload} | |
| className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors" | |
| title="Download .md" | |
| > | |
| <Download size={18} /> | |
| </button> | |
| <div className="w-px h-6 bg-gray-200 dark:bg-gray-700 mx-1"></div> | |
| <button | |
| onClick={onClose} | |
| className="p-2 text-gray-500 hover:text-red-600 dark:text-gray-400 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors" | |
| > | |
| <X size={20} /> | |
| </button> | |
| </div> | |
| </div> | |
| {/* Body */} | |
| <div className="flex-1 flex overflow-hidden"> | |
| {/* Content */} | |
| <div className="flex-1 overflow-y-auto custom-scrollbar p-8 bg-white dark:bg-gray-900"> | |
| <div className="prose dark:prose-invert max-w-none prose-headings:font-semibold prose-a:text-blue-600 dark:prose-a:text-blue-400 prose-code:text-blue-600 dark:prose-code:text-blue-400 prose-pre:bg-gray-50 dark:prose-pre:bg-gray-800 prose-pre:border prose-pre:border-gray-100 dark:prose-pre:border-gray-700 isolate"> | |
| <MarkdownRenderer | |
| content={content} | |
| /> | |
| </div> | |
| </div> | |
| {/* TOC Sidebar */} | |
| {toc.length > 0 && showToc && ( | |
| <div className="w-64 border-l border-gray-200 dark:border-gray-800 bg-gray-50/50 dark:bg-gray-900/50 backdrop-blur overflow-y-auto custom-scrollbar p-4 animate-in slide-in-from-right-5 duration-200"> | |
| <h4 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-4"> | |
| Table of Contents | |
| </h4> | |
| <nav className="space-y-1"> | |
| {toc.map((item, index) => ( | |
| <button | |
| key={`${item.id}-${index}`} | |
| onClick={() => scrollToHeader(item.id)} | |
| className={` | |
| block w-full text-left px-2 py-1.5 rounded text-sm transition-colors | |
| ${item.level === 1 ? 'font-medium text-gray-900 dark:text-gray-100' : ''} | |
| ${item.level === 2 ? 'pl-4 text-gray-600 dark:text-gray-400' : ''} | |
| ${item.level === 3 ? 'pl-8 text-gray-500 dark:text-gray-500' : ''} | |
| hover:bg-gray-100 dark:hover:bg-gray-800 | |
| `} | |
| > | |
| {item.text} | |
| </button> | |
| ))} | |
| </nav> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |