Files
doc_ai_frontend/src/components/ExportSidebar.tsx
2026-02-05 18:22:30 +08:00

387 lines
12 KiB
TypeScript

import { useState } from 'react';
import { X, Check, Copy, Download, Code2, Image as ImageIcon, FileText, Loader2, LucideIcon } from 'lucide-react';
import { RecognitionResult } from '../types';
import { generateImageFromElement, copyImageToClipboard, downloadImage } from '../lib/imageGenerator';
import { API_BASE_URL } from '../config/env';
import { tokenManager } from '../lib/api';
import { trackExportEvent } from '../lib/analytics';
import { useLanguage } from '../contexts/LanguageContext';
import toast, { Toaster } from 'react-hot-toast';
interface ExportSidebarProps {
isOpen: boolean;
onClose: () => void;
result: RecognitionResult | null;
}
type ExportCategory = 'Code' | 'Image' | 'File';
interface ExportOption {
id: string;
label: string;
category: ExportCategory;
getContent: (result: RecognitionResult) => string | null;
isDownload?: boolean; // If true, triggers file download instead of copy
extension?: string;
}
export default function ExportSidebar({ isOpen, onClose, result }: ExportSidebarProps) {
const { t } = useLanguage();
const [copiedId, setCopiedId] = useState<string | null>(null);
const [exportingId, setExportingId] = useState<string | null>(null);
if (!result) return null;
const exportOptions: ExportOption[] = [
// Code Category
{
id: 'markdown',
label: 'Markdown',
category: 'Code',
getContent: (r) => r.markdown_content
},
{
id: 'latex',
label: 'LaTeX',
category: 'Code',
getContent: (r) => r.latex_content
},
{
id: 'latex_inline',
label: 'LaTeX (Inline)',
category: 'Code',
getContent: (r) => {
if (!r.latex_content) return null;
// Remove existing delimiters like \[ \], \( \), $$, or $
let content = r.latex_content.trim();
content = content.replace(/^\\\[/, '').replace(/\\\]$/, '');
content = content.replace(/^\\\(/, '').replace(/\\\)$/, '');
content = content.replace(/^\$\$/, '').replace(/\$\$$/, '');
content = content.replace(/^\$/, '').replace(/\$$/, '');
content = content.trim();
return `\\(${content}\\)`;
}
},
{
id: 'latex_display',
label: 'LaTeX (Display)',
category: 'Code',
getContent: (r) => {
if (!r.latex_content) return null;
// Remove existing delimiters like \[ \], \( \), $$, or $
let content = r.latex_content.trim();
content = content.replace(/^\\\[/, '').replace(/\\\]$/, '');
content = content.replace(/^\\\(/, '').replace(/\\\)$/, '');
content = content.replace(/^\$\$/, '').replace(/\$\$$/, '');
content = content.replace(/^\$/, '').replace(/\$$/, '');
content = content.trim();
return `\\[${content}\\]`;
}
},
{
id: 'mathml',
label: 'MathML',
category: 'Code',
getContent: (r) => r.mathml_content
},
{
id: 'mathml_mml',
label: 'MathML (MML)',
category: 'Code',
getContent: (r) => r.mml
},
// Image Category
{
id: 'rendered_image',
label: 'Rendered Image',
category: 'Image',
getContent: (r) => r.markdown_content,
},
// File Category
{
id: 'docx',
label: 'DOCX',
category: 'File',
getContent: (r) => r.markdown_content,
isDownload: true,
extension: 'docx'
}
];
// Handle DOCX export via API
const handleFileExport = async (type: 'docx') => {
if (!result?.id) return;
setExportingId(type);
try {
const token = tokenManager.getToken();
const response = await fetch(`${API_BASE_URL}/task/export`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { 'Authorization': token } : {}),
},
body: JSON.stringify({
task_no: result.id,
type: type,
}),
});
if (!response.ok) {
throw new Error(`Export failed: ${response.statusText}`);
}
// Get the blob from response
const blob = await response.blob();
// Create download link
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `export.${type}`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
setCopiedId(type);
setTimeout(() => {
setCopiedId(null);
onClose();
}, 1000);
} catch (err) {
console.error('Export failed:', err);
alert(t.export.failed);
} finally {
setExportingId(null);
}
};
// Handle image generation from Markdown preview
const handleImageGeneration = async (action: 'copy' | 'download') => {
// We capture the rendered result directly from the DOM
const elementId = 'markdown-preview-content';
setExportingId('rendered_image');
try {
const dataUrl = await generateImageFromElement(elementId, {
format: 'png',
scale: 2,
padding: 24,
}) as string;
if (action === 'copy') {
await copyImageToClipboard(dataUrl);
} else {
downloadImage(dataUrl, 'rendered_image.png');
}
setCopiedId('rendered_image');
setTimeout(() => {
setCopiedId(null);
onClose();
}, 1000);
} catch (err) {
console.error('Failed to generate image:', err);
alert(`${t.export.imageFailed}: ${err}`);
} finally {
setExportingId(null);
}
};
const handleAction = async (option: ExportOption) => {
// Analytics tracking
if (result?.id) {
trackExportEvent(
result.id,
option.id,
exportOptions.map(o => o.id)
);
}
// Handle DOCX export via API
if (option.id === 'docx') {
await handleFileExport('docx');
return;
}
// Handle image generation from Markdown
if (option.id === 'rendered_image') {
await handleImageGeneration('copy');
return;
}
const content = option.getContent(result);
// Check if content is empty and show toast
if (!content) {
toast.error(t.export.noContent, {
duration: 2000,
position: 'top-center',
style: {
background: '#fff',
color: '#1f2937',
padding: '16px 20px',
borderRadius: '12px',
boxShadow: '0 10px 40px rgba(59, 130, 246, 0.15), 0 2px 8px rgba(59, 130, 246, 0.1)',
border: '1px solid #dbeafe',
fontSize: '14px',
fontWeight: '500',
maxWidth: '900px',
lineHeight: '1.5',
},
iconTheme: {
primary: '#ef4444',
secondary: '#ffffff',
},
});
return;
}
setExportingId(option.id);
try {
if (option.isDownload) {
// Simulate download (for other file types if any)
const blob = new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `export.${option.extension}`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} else {
// Standard Text Copy
await navigator.clipboard.writeText(content);
}
setCopiedId(option.id);
setTimeout(() => {
setCopiedId(null);
onClose(); // Auto close after action
}, 1000);
} catch (err) {
console.error('Action failed:', err);
toast.error(t.export.failed, {
duration: 3000,
position: 'top-center',
});
} finally {
setExportingId(null);
}
};
const categories: { id: ExportCategory; icon: LucideIcon; label: string }[] = [
{ id: 'Code', icon: Code2, label: t.export.categories.code },
{ id: 'Image', icon: ImageIcon, label: t.export.categories.image },
{ id: 'File', icon: FileText, label: t.export.categories.file },
];
return (
<>
{/* Toast Container with custom configuration */}
<Toaster
position="top-center"
toastOptions={{
duration: 3000,
style: {
background: '#fff',
color: '#1f2937',
fontSize: '14px',
fontWeight: '500',
borderRadius: '12px',
boxShadow: '0 10px 40px rgba(59, 130, 246, 0.15), 0 2px 8px rgba(59, 130, 246, 0.1)',
maxWidth: '420px',
},
success: {
iconTheme: {
primary: '#10b981',
secondary: '#ffffff',
},
style: {
border: '1px solid #d1fae5',
},
},
error: {
iconTheme: {
primary: '#3b82f6',
secondary: '#ffffff',
},
style: {
border: '1px solid #dbeafe',
},
},
}}
/>
{/* Backdrop */}
{isOpen && (
<div
className="absolute inset-0 bg-black/20 backdrop-blur-[1px] z-40 transition-opacity"
onClick={onClose}
/>
)}
{/* Sidebar Panel */}
<div
className={`
absolute top-0 right-0 bottom-0 w-80 bg-white shadow-2xl z-50 transform transition-transform duration-300 ease-in-out border-l border-gray-100 flex flex-col
${isOpen ? 'translate-x-0' : 'translate-x-full'}
`}
>
<div className="flex items-center justify-between px-6 py-5 border-b border-gray-100 shrink-0">
<h2 className="text-lg font-bold text-gray-900">{t.export.title}</h2>
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
<X size={20} className="text-gray-500" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-6 space-y-8">
{categories.map((category) => (
<div key={category.id} className="space-y-3">
<div className="flex items-center gap-2 text-gray-400 px-1">
<category.icon size={16} />
<span className="text-xs font-semibold uppercase tracking-wider">{category.label}</span>
</div>
<div className="space-y-2">
{exportOptions
.filter(opt => opt.category === category.id)
.map(option => (
<button
key={option.id}
onClick={() => handleAction(option)}
className="w-full flex items-center justify-between p-3 bg-gray-50 hover:bg-blue-50 hover:text-blue-600 border border-transparent hover:border-blue-200 rounded-lg group transition-all text-left"
>
<span className="text-sm font-medium text-gray-700 group-hover:text-blue-700">
{option.label}
</span>
<div className="text-gray-400 group-hover:text-blue-600">
{exportingId === option.id ? (
<Loader2 size={16} className="animate-spin text-blue-500" />
) : copiedId === option.id ? (
<Check size={16} className="text-green-500" />
) : option.isDownload ? (
<Download size={16} />
) : (
<Copy size={16} className="opacity-0 group-hover:opacity-100 transition-opacity" />
)}
</div>
</button>
))
}
</div>
</div>
))}
</div>
</div>
</>
);
}