293 lines
8.7 KiB
TypeScript
293 lines
8.7 KiB
TypeScript
import { useState } from 'react';
|
|
import { X, Check, Copy, Download, Code2, Image as ImageIcon, FileText, Loader2 } from 'lucide-react';
|
|
import { RecognitionResult } from '../types';
|
|
import { convertMathmlToOmml, wrapOmmlForClipboard } from '../lib/ommlConverter';
|
|
import { generateImageFromElement, copyImageToClipboard, downloadImage } from '../lib/imageGenerator';
|
|
import { API_BASE_URL } from '../config/env';
|
|
import { tokenManager } from '../lib/api';
|
|
|
|
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 [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: 'mathml',
|
|
label: 'MathML',
|
|
category: 'Code',
|
|
getContent: (r) => r.mathml_content
|
|
},
|
|
{
|
|
id: 'mathml_word',
|
|
label: 'Word MathML',
|
|
category: 'Code',
|
|
getContent: (r) => r.mathml_word_content
|
|
},
|
|
// 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('导出失败,请重试');
|
|
} 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(`生成图片失败: ${err}`);
|
|
} finally {
|
|
setExportingId(null);
|
|
}
|
|
};
|
|
|
|
const handleAction = async (option: ExportOption) => {
|
|
// 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;
|
|
}
|
|
|
|
let content = option.getContent(result);
|
|
|
|
// Fallback: If Word MathML is missing, try to convert from MathML
|
|
if (option.id === 'mathml_word' && !content && result.mathml_content) {
|
|
try {
|
|
const omml = await convertMathmlToOmml(result.mathml_content);
|
|
if (omml) {
|
|
content = wrapOmmlForClipboard(omml);
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to convert MathML to OMML:', err);
|
|
}
|
|
}
|
|
|
|
if (!content) 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);
|
|
} finally {
|
|
setExportingId(null);
|
|
}
|
|
};
|
|
|
|
const categories: { id: ExportCategory; icon: any; label: string }[] = [
|
|
{ id: 'Code', icon: Code2, label: 'Code' },
|
|
{ id: 'Image', icon: ImageIcon, label: 'Image' },
|
|
{ id: 'File', icon: FileText, label: 'File' },
|
|
];
|
|
|
|
return (
|
|
<>
|
|
{/* 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">Export</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>
|
|
</>
|
|
);
|
|
}
|
|
|