Spaces:
Running
Running
File size: 8,840 Bytes
69a3dd3 |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 |
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>
);
}
|