manus-clone-cn / src /components /FilePreviewModal.tsx
Trae Assistant
Initial commit
69a3dd3
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>
);
}