Commit 30bab8d2 authored by fisherdaddy's avatar fisherdaddy

feat: add Document Translator

parent 44f4813f
......@@ -8,6 +8,8 @@
<title>AI工具箱 | 智能助手集合</title>
<link rel="stylesheet" href="/src/styles/main.css" />
<link rel="icon" href="/favicon.ico" type="image/x-icon" />
<!-- PDF.js library -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/2.9.359/pdf.min.js"></script>
<!-- Google tag (gtag.js) -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-3PSXKB099C"></script>
<script>
......
......@@ -11,6 +11,7 @@ const ImageTools = lazy(() => import('./pages/ImageTools'));
const Blog = lazy(() => import('./pages/Blog'));
const AIProduct = lazy(() => import('./pages/AIProduct'));
const Translator = lazy(() => import('./pages/Translator'));
const DocumentTranslator = lazy(() => import('./pages/DocumentTranslator'));
const JsonFormatter = lazy(() => import('./components/JsonFormatter'));
const MarkdownToImage = lazy(() => import('./components/MarkdownToImage'));
......@@ -54,6 +55,7 @@ function App() {
<Route path="/ai-products" element={<AIProduct />} />
<Route path="/blog" element={<Blog />} />
<Route path="/translator" element={<Translator />} />
<Route path="/document-translator" element={<DocumentTranslator />} />
<Route path="/markdown-to-image" element={<MarkdownToImage />} />
<Route path="/json-formatter" element={<JsonFormatter />} />
......
import React, { useState, useRef, useEffect } from 'react';
import { Select, Button, Spin, Tooltip, Empty, Upload, message, Modal } from 'antd';
import {
UploadOutlined,
SoundOutlined,
CopyOutlined,
InfoCircleOutlined,
GlobalOutlined,
FileOutlined,
FileImageOutlined,
SyncOutlined,
PauseOutlined,
LoginOutlined
} from '@ant-design/icons';
import { useTranslation } from '../js/i18n';
import { useNavigate, useLocation } from 'react-router-dom';
// Import PDF.js
// Note: In a real implementation, you might want to properly set up PDF.js with its worker
const pdfjsLib = window.pdfjsLib;
if (pdfjsLib) {
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/2.9.359/pdf.worker.min.js';
}
// API configuration - reusing the same API as the Translator component
const API_KEY = '28c81f920240b0fdbca940e07b86b8db';
const API_SECRET = 'd6e57784b134d09a8bed9ca004c98b4f';
const API_BASE_URL = 'https://www.heytransl.com';
const API_DOCUMENT_URL = `${API_BASE_URL}/api/translate/document`;
/**
* Generate authentication headers
* @param {string} apiKey - API key
* @param {string} apiSecret - API secret
* @param {object} body - Request body
* @returns {Promise<object>} - Object containing authentication headers
*/
function generateHeaders(apiKey, apiSecret, body) {
const timestamp = Math.floor(Date.now() / 1000).toString();
const bodyStr = JSON.stringify(body);
const messageToSign = `${apiKey}${timestamp}${bodyStr}`;
// Generate HMAC SHA-256 signature
const encoder = new TextEncoder();
const key = encoder.encode(apiSecret);
const message = encoder.encode(messageToSign);
return crypto.subtle.importKey(
'raw',
key,
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
).then(key => {
return crypto.subtle.sign(
'HMAC',
key,
message
);
}).then(signature => {
const hashArray = Array.from(new Uint8Array(signature));
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
return {
'X-API-Key': apiKey,
'X-Timestamp': timestamp,
'X-Signature': hashHex,
'Content-Type': 'application/json',
'Accept': 'text/event-stream'
};
});
}
/**
* Convert file to base64 string
* @param {File} file - File object
* @returns {Promise<string>} - base64 string
*/
function fileToBase64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => resolve(reader.result);
reader.onerror = error => reject(error);
});
}
/**
* Extract translation content from SSE response
* @param {object} data - Parsed JSON object
* @returns {string|null} - Extracted translation content or null
*/
function extractTranslationContent(data) {
if (!data) return null;
try {
// Handle various response formats
if (data.document_translation) {
return data.document_translation;
}
if (data.translation_progress) {
return data.translation_progress;
}
// For compatibility with existing extractTranslationContent function
if (data.choices && data.choices.length > 0) {
if (data.choices[0].delta && data.choices[0].delta.content !== undefined) {
return data.choices[0].delta.content || '';
}
if (data.choices[0].message && data.choices[0].message.content !== undefined) {
return data.choices[0].message.content || '';
}
if (data.choices[0].text !== undefined) {
return data.choices[0].text || '';
}
}
// Handle other possible response formats
if (data.translated_text !== undefined) {
return data.translated_text || '';
}
if (data.content !== undefined) {
return data.content || '';
}
if (data.text !== undefined) {
return data.text || '';
}
console.warn('Unknown translation data structure:', data);
return '';
} catch (e) {
console.error('Failed to extract translation content:', e, data);
return '';
}
}
/**
* Parse SSE event data
* @param {string} data - SSE event data
* @returns {object|null} - Parsed JSON object, or null
*/
function parseSSEData(data) {
if (!data) return null;
if (data === '[DONE]') return null;
let jsonData = data;
if (typeof data === 'string') {
if (data.startsWith('data:')) {
jsonData = data.substring(5).trim();
}
if (jsonData === '[DONE]') return null;
try {
return JSON.parse(jsonData);
} catch (e) {
if (jsonData.trim()) {
return { text: jsonData.trim() };
}
return null;
}
} else if (typeof data === 'object') {
return data;
}
return null;
}
/**
* Process SSE stream
* @param {Response} response - Fetch response object
* @param {function} onData - Callback for processing each data chunk
* @param {function} onDone - Callback for when processing is complete
* @param {function} onError - Callback for errors
*/
async function processSSEStream(response, onData, onDone, onError) {
if (!response.ok) {
onError(new Error(`HTTP error: ${response.status}`));
return;
}
if (!response.body) {
onError(new Error('Response has no readable data stream'));
return;
}
try {
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) {
if (buffer.trim()) {
const lines = buffer.split('\n');
for (const line of lines) {
if (line.startsWith('data:')) {
const data = line.substring(5).trim();
if (data === '[DONE]') {
onDone();
return;
}
const parsedData = parseSSEData(data);
if (parsedData) onData(parsedData);
}
}
}
break;
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data:')) {
const data = line.substring(5).trim();
if (data === '[DONE]') {
onDone();
return;
}
const parsedData = parseSSEData(data);
if (parsedData) onData(parsedData);
}
}
}
onDone();
} catch (error) {
onError(error);
}
}
const DocumentTranslatorContent = ({ currentLanguage, onLanguageChange }) => {
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const [targetLanguage, setTargetLanguage] = useState(currentLanguage || '中文');
const [loading, setLoading] = useState(false);
const [currentDocument, setCurrentDocument] = useState(null);
const [pdfInstance, setPdfInstance] = useState(null);
const [pdfScale, setPdfScale] = useState(1);
const [documentData, setDocumentData] = useState(null);
const [displayMode, setDisplayMode] = useState('translation'); // 'translation', 'bilingual', 'original'
const [activeBlock, setActiveBlock] = useState(null);
const pdfContentRef = useRef(null);
const translationContentRef = useRef(null);
const fileInputRef = useRef(null);
const dropAreaRef = useRef(null);
const abortControllerRef = useRef(null);
const [isUserAuthenticated, setIsUserAuthenticated] = useState(false);
// 检查用户登录状态的函数
const checkAuthentication = () => {
const isAuth = localStorage.getItem('user') !== null;
setIsUserAuthenticated(isAuth);
return isAuth;
};
// 组件挂载和路由变化时检查认证状态
useEffect(() => {
checkAuthentication();
// 监听 storage 事件,处理其他标签页登录/登出的情况
const handleStorageChange = (e) => {
if (e.key === 'user') {
checkAuthentication();
}
};
window.addEventListener('storage', handleStorageChange);
return () => {
window.removeEventListener('storage', handleStorageChange);
};
}, [location]);
// 检查并恢复待处理的文件上传
useEffect(() => {
const checkPendingUpload = async () => {
if (checkAuthentication()) {
try {
const pendingFileData = sessionStorage.getItem('pendingDocumentUpload');
if (pendingFileData) {
console.log("找到待处理的文件上传:", pendingFileData);
const fileData = JSON.parse(pendingFileData);
// 从存储的数据创建 File 对象
const byteCharacters = atob(fileData.base64Data.split(',')[1]);
const byteArrays = [];
for (let offset = 0; offset < byteCharacters.length; offset += 512) {
const slice = byteCharacters.slice(offset, offset + 512);
const byteNumbers = new Array(slice.length);
for (let i = 0; i < slice.length; i++) {
byteNumbers[i] = slice.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
byteArrays.push(byteArray);
}
const blob = new Blob(byteArrays, { type: fileData.type });
const file = new File([blob], fileData.name, { type: fileData.type });
// 清除存储的数据
sessionStorage.removeItem('pendingDocumentUpload');
// 延迟一下再处理文件,确保组件已完全加载
setTimeout(() => {
// 处理文件
handleDocumentFile(file);
message.success(t('documentTranslator.continuePendingUpload'));
}, 500);
}
} catch (error) {
console.error('Error processing pending upload:', error);
sessionStorage.removeItem('pendingDocumentUpload');
}
}
};
// 等待页面完全加载再检查
setTimeout(checkPendingUpload, 300);
}, []);
const languages = [
{ value: '中文', label: '中文 (Chinese)' },
{ value: 'English', label: 'English' },
{ value: '日本語', label: '日本語 (Japanese)' },
{ value: '한국어', label: '한국어 (Korean)' },
{ value: 'Español', label: 'Español (Spanish)' },
{ value: 'Français', label: 'Français (French)' },
{ value: 'Deutsch', label: 'Deutsch (German)' },
{ value: 'Русский', label: 'Русский (Russian)' },
{ value: 'Português', label: 'Português (Portuguese)' },
{ value: 'Italiano', label: 'Italiano (Italian)' },
{ value: 'العربية', label: 'العربية (Arabic)' },
];
// Update local state when props change
useEffect(() => {
if (currentLanguage && currentLanguage !== targetLanguage) {
setTargetLanguage(currentLanguage);
}
}, [currentLanguage]);
// Cleanup on component unmount
useEffect(() => {
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, []);
// Setup file drop event handlers
useEffect(() => {
// Clipboard paste handler
const handlePaste = (e) => {
if (!checkAuthentication()) {
// For clipboard pastes, just redirect to login without saving
window.location.href = '/login';
return;
}
if (e.clipboardData && e.clipboardData.items) {
const items = e.clipboardData.items;
for (const item of items) {
if (item.type.indexOf('application/pdf') !== -1) {
const file = item.getAsFile();
handleDocumentFile(file);
message.success(t('documentTranslator.documentAddedFromClipboard'));
break;
}
}
}
};
// Drag and drop handlers
const handleDragOver = (e) => {
e.preventDefault();
if (dropAreaRef.current) {
dropAreaRef.current.classList.add('border-indigo-500');
}
};
const handleDragLeave = (e) => {
e.preventDefault();
if (dropAreaRef.current) {
dropAreaRef.current.classList.remove('border-indigo-500');
}
};
const handleDrop = (e) => {
e.preventDefault();
if (dropAreaRef.current) {
dropAreaRef.current.classList.remove('border-indigo-500');
}
if (!checkAuthentication()) {
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
const file = e.dataTransfer.files[0];
if (file.type === 'application/pdf') {
saveFileForLaterUpload(file);
return;
}
}
window.location.href = '/login';
return;
}
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
const file = e.dataTransfer.files[0];
if (file.type !== 'application/pdf') {
message.error(t('documentTranslator.uploadPdfOnly'));
return;
}
handleDocumentFile(file);
message.success(t('documentTranslator.documentAdded'));
}
};
document.addEventListener('paste', handlePaste);
const dropArea = dropAreaRef.current;
if (dropArea) {
dropArea.addEventListener('dragover', handleDragOver);
dropArea.addEventListener('dragleave', handleDragLeave);
dropArea.addEventListener('drop', handleDrop);
}
return () => {
document.removeEventListener('paste', handlePaste);
if (dropArea) {
dropArea.removeEventListener('dragover', handleDragOver);
dropArea.removeEventListener('dragleave', handleDragLeave);
dropArea.removeEventListener('drop', handleDrop);
}
};
}, [navigate]);
// Handle window resize for PDF scale
useEffect(() => {
const handleResize = debounce(() => {
if (currentDocument) {
loadPDF(currentDocument.url);
}
}, 250);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [currentDocument]);
// Helper function for debouncing
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
const saveFileForLaterUpload = async (file) => {
if (!file) return;
try {
console.log("正在保存文件供登录后上传:", file.name);
// 转换文件为 base64
const base64Data = await fileToBase64(file);
// 保存文件信息到 sessionStorage
const fileData = {
name: file.name,
type: file.type,
base64Data
};
// 使用JSON.stringify保存数据,确保正确序列化
sessionStorage.setItem('pendingDocumentUpload', JSON.stringify(fileData));
sessionStorage.setItem('redirectAfterLogin', '/document-translator');
// 跳转到登录页
// 使用window.location.href强制刷新而不是使用React Router的navigate
window.location.href = '/login';
} catch (error) {
console.error('Error saving file for later:', error);
window.location.href = '/login';
}
};
const handleDocumentFile = (file) => {
if (!file) return;
// 检查用户是否已登录
if (!checkAuthentication()) {
console.log("用户未登录,保存文件并跳转到登录页");
saveFileForLaterUpload(file);
return;
}
console.log("用户已登录,直接处理文件:", file.name);
const reader = new FileReader();
reader.onload = (event) => {
setCurrentDocument({
uid: Date.now(),
name: file.name,
status: 'done',
url: event.target.result,
originFileObj: file
});
// 加载PDF并翻译
loadPDF(event.target.result);
translateDocument(file, targetLanguage);
};
reader.readAsDataURL(file);
};
const translateDocument = async (file, language) => {
if (!file) {
message.warning(t('documentTranslator.uploadFirst'));
return;
}
// 检查用户是否已登录
if (!checkAuthentication()) {
console.log("翻译时发现用户未登录");
saveFileForLaterUpload(file);
return;
}
// Abort previous request if any
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
// Create new AbortController
abortControllerRef.current = new AbortController();
const { signal } = abortControllerRef.current;
setLoading(true);
setDocumentData(null);
try {
// Convert document to base64
const base64Document = await fileToBase64(file);
// Build request body
const requestBody = {
document_base64: base64Document,
document_name: file.name,
target_language: language || targetLanguage,
stream: true
};
// Generate base auth headers (API Key, Signature)
const baseHeaders = await generateHeaders(
API_KEY,
API_SECRET,
requestBody
);
// Add user authentication token if available
const finalHeaders = { ...baseHeaders };
const userDataString = localStorage.getItem('user');
if (userDataString) {
try {
const userData = JSON.parse(userDataString);
if (userData && userData.token) {
finalHeaders['Authorization'] = `Bearer ${userData.token}`;
} else {
console.warn('User token not found in localStorage data.');
}
} catch (e) {
console.error('Failed to parse user data from localStorage:', e);
}
}
const response = await fetch(API_DOCUMENT_URL, {
method: 'POST',
headers: finalHeaders, // Use the combined headers
body: JSON.stringify(requestBody),
signal
});
let translationResult = null;
// Process SSE stream
await processSSEStream(
response,
// Data callback
(data) => {
const content = extractTranslationContent(data);
if (content) {
if (typeof content === 'object') {
// For structured translation data
translationResult = content;
setDocumentData(content);
} else if (data.translation_progress) {
// Handle progress updates if needed
console.log('Translation progress:', data.translation_progress);
}
}
},
// Complete callback
() => {
setLoading(false);
if (translationResult) {
setDocumentData(translationResult);
}
},
// Error callback
(error) => {
if (error.name !== 'AbortError') {
console.error('Document translation error:', error);
message.error(t('documentTranslator.serviceUnavailable'));
// For development testing - use mock data
if (process.env.NODE_ENV === 'development') {
setTimeout(() => {
// Mock data structure
setDocumentData({
pages: [
{
page_idx: 0,
para_blocks: [
{
type: 'title',
text: 'Document Translation Example',
translation: '文档翻译示例',
bbox: [50, 50, 500, 100]
},
{
type: 'text',
text: 'This is an example of document translation with immersive bilingual reading.',
translation: '这是一个带有沉浸式双语阅读的文档翻译示例。',
bbox: [50, 150, 500, 200]
}
]
}
]
});
setLoading(false);
}, 1500);
} else {
setLoading(false);
}
} else {
setLoading(false);
}
}
);
} catch (error) {
if (error.name !== 'AbortError') {
console.error('Document translation error:', error);
message.error(t('documentTranslator.serviceUnavailable'));
// For development testing
if (process.env.NODE_ENV === 'development') {
setTimeout(() => {
// Add mock data here similar to above
setLoading(false);
}, 1500);
} else {
setLoading(false);
}
} else {
setLoading(false);
}
}
};
const loadPDF = async (url) => {
if (!pdfjsLib) {
message.error(t('documentTranslator.pdfJsNotLoaded'));
return;
}
try {
// Clear previous content
if (pdfContentRef.current) {
pdfContentRef.current.innerHTML = '';
}
const loadingTask = pdfjsLib.getDocument(url);
const pdf = await loadingTask.promise;
setPdfInstance(pdf);
const pdfContainer = pdfContentRef.current;
if (!pdfContainer) return;
for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) {
const page = await pdf.getPage(pageNum);
const originalViewport = page.getViewport({ scale: 1 });
const containerWidth = pdfContainer.clientWidth - 40;
const scale = containerWidth / originalViewport.width;
setPdfScale(scale);
const dpr = window.devicePixelRatio || 2;
const scaledViewport = page.getViewport({ scale: scale * dpr });
const pageDiv = document.createElement('div');
pageDiv.className = 'pdf-page';
pageDiv.setAttribute('data-page-number', pageNum);
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
canvas.height = scaledViewport.height;
canvas.width = scaledViewport.width;
canvas.style.width = `${scaledViewport.width / dpr}px`;
canvas.style.height = `${scaledViewport.height / dpr}px`;
const renderContext = {
canvasContext: context,
viewport: scaledViewport
};
pageDiv.appendChild(canvas);
pdfContainer.appendChild(pageDiv);
await page.render(renderContext);
}
} catch (error) {
console.error('Error loading PDF:', error);
message.error(t('documentTranslator.pdfLoadFailed'));
}
};
const renderTranslations = () => {
if (!documentData || !documentData.pages) return null;
return documentData.pages.map((page, pageIndex) => {
const pageNumber = page.page_idx + 1;
return page.para_blocks.map((paraBlock, blockIndex) => {
// Create paragraph block
const paraDiv = (
<div
key={`${pageNumber}-${blockIndex}`}
className={`paragraph-block ${activeBlock === `${pageNumber}-${blockIndex}` ? 'active' : ''}`}
onClick={() => {
setActiveBlock(`${pageNumber}-${blockIndex}`);
highlightOriginalText(pageNumber, paraBlock.bbox);
}}
>
{/* Original text block */}
{(paraBlock.text || paraBlock.type === 'image' || paraBlock.type === 'table') && (
<div
className="origin-block"
style={{
display: displayMode === 'original' || displayMode === 'bilingual' ? 'block' : 'none',
fontFamily: '"Merriweather", Georgia, serif',
fontSize: '16px',
lineHeight: 1.8,
color: '#555',
padding: '5px'
}}
>
{renderParaBlock(paraBlock, 'original')}
</div>
)}
{/* Translation block */}
{(paraBlock.translation || paraBlock.type === 'image' || paraBlock.type === 'table') && (
<div
className="translation-block"
style={{
display: displayMode === 'translation' || displayMode === 'bilingual' ? 'block' : 'none',
fontFamily: '"Noto Sans SC", "Microsoft YaHei", Arial, sans-serif',
fontSize: '18px',
lineHeight: 1.6,
color: '#333',
padding: '5px',
backgroundColor: displayMode === 'bilingual' ? '#f3f4f6' : 'transparent'
}}
>
{renderParaBlock(paraBlock, 'translation')}
</div>
)}
</div>
);
return paraDiv;
});
});
};
const renderParaBlock = (paraBlock, mode) => {
const isOriginal = mode === 'original';
const content = isOriginal ? paraBlock.text : paraBlock.translation;
switch (paraBlock.type) {
case 'title':
return <h1>{content}</h1>;
case 'text':
return <p>{content}</p>;
case 'image':
// Render image with caption
return (
<div className="image-block">
{paraBlock.image_url && <img src={paraBlock.image_url} alt={t('documentTranslator.imageAlt')} />}
{paraBlock.caption && <p className="image-caption">{isOriginal ? paraBlock.caption : paraBlock.caption_translation}</p>}
</div>
);
case 'table':
// Render table with caption
return (
<div className="table-block">
{paraBlock.table_html && <div dangerouslySetInnerHTML={{ __html: paraBlock.table_html }} />}
{paraBlock.caption && <p className="table-caption">{isOriginal ? paraBlock.caption : paraBlock.caption_translation}</p>}
</div>
);
default:
return <p>{content}</p>;
}
};
const highlightOriginalText = (pageNumber, bbox) => {
// Remove previous highlights
document.querySelectorAll('.highlighted').forEach(el => {
el.parentElement.removeChild(el);
});
// Find corresponding page
const pageDiv = document.querySelector(`.pdf-page[data-page-number="${pageNumber}"]`);
if (!pageDiv) return;
// Create highlight element
const [x1, y1, x2, y2] = bbox;
const highlightEl = document.createElement('div');
highlightEl.className = 'highlighted';
highlightEl.style.position = 'absolute';
highlightEl.style.left = `${x1 * pdfScale}px`;
highlightEl.style.top = `${y1 * pdfScale}px`;
highlightEl.style.width = `${(x2 - x1) * pdfScale}px`;
highlightEl.style.height = `${(y2 - y1) * pdfScale}px`;
highlightEl.style.backgroundColor = 'yellow';
highlightEl.style.opacity = '0.5';
pageDiv.appendChild(highlightEl);
// Scroll to highlight
pageDiv.scrollIntoView({ behavior: 'smooth', block: 'center' });
};
const handleUpload = (info) => {
if (!checkAuthentication()) {
if (info.file && info.file.originFileObj) {
saveFileForLaterUpload(info.file.originFileObj);
} else {
window.location.href = '/login';
}
return;
}
const { file } = info;
if (file.status !== 'uploading') {
handleDocumentFile(file.originFileObj);
}
};
const removeDocument = () => {
setCurrentDocument(null);
setDocumentData(null);
// Clear PDF container
if (pdfContentRef.current) {
pdfContentRef.current.innerHTML = '';
}
// Clear translation container
if (translationContentRef.current) {
translationContentRef.current.innerHTML = '';
}
};
const handleLanguageChange = (value) => {
setTargetLanguage(value);
// Propagate to parent component if needed
if (onLanguageChange) {
onLanguageChange(value);
}
// Retranslate if document exists
if (currentDocument?.originFileObj && checkAuthentication()) {
translateDocument(currentDocument.originFileObj, value);
}
};
const copyToClipboard = (text) => {
navigator.clipboard.writeText(text).then(() => {
message.success(t('documentTranslator.copied'));
}, (err) => {
message.error(t('documentTranslator.copyFailed'));
console.error('Copy failed: ', err);
});
};
return (
<>
<div className="flex flex-col items-center pt-6 pb-6 px-4 sm:px-6">
<div className="w-full">
<div className="bg-white rounded-xl shadow-lg overflow-hidden border border-gray-100">
{/* Header section */}
<div className="bg-gray-50 p-4 border-b border-gray-200">
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex items-center">
<GlobalOutlined className="text-indigo-500 mr-2" />
<span className="font-medium mr-2">{t('translator.targetLanguageLabel')}</span>
<Select
value={targetLanguage}
onChange={(value) => {
setTargetLanguage(value);
if (onLanguageChange) {
onLanguageChange(value);
}
// Retranslate if document exists
if (currentDocument?.originFileObj && checkAuthentication()) {
translateDocument(currentDocument.originFileObj, value);
}
}}
options={languages}
style={{ width: 180 }}
dropdownStyle={{ zIndex: 1001 }}
/>
</div>
<div className="flex items-center">
<span className="font-medium mr-2">{t('translator.displayModeLabel')}</span>
<Select
value={displayMode}
onChange={setDisplayMode}
options={[
{ value: 'translation', label: t('translator.displayModes.translation') },
{ value: 'bilingual', label: t('translator.displayModes.bilingual') },
{ value: 'original', label: t('translator.displayModes.original') }
]}
style={{ width: 180 }}
dropdownStyle={{ zIndex: 1001 }}
/>
</div>
{currentDocument && (
<div className="flex items-center">
<span className="text-gray-500 mr-2">{currentDocument.name}</span>
<Button
type="text"
danger
icon={<UploadOutlined />}
onClick={() => fileInputRef.current?.click()}
>
{t('documentTranslator.changeDocument')}
</Button>
<input
type="file"
ref={fileInputRef}
onChange={(e) => {
if (!checkAuthentication()) {
window.location.href = '/login';
e.target.value = '';
return;
}
if (e.target.files && e.target.files[0]) {
const file = e.target.files[0];
if (file.type === 'application/pdf') {
handleDocumentFile(file);
} else {
message.error(t('documentTranslator.uploadPdfOnly'));
}
}
e.target.value = '';
}}
style={{ display: 'none' }}
accept="application/pdf"
/>
</div>
)}
</div>
</div>
{/* Main content area */}
{!currentDocument ? (
<div
ref={dropAreaRef}
className="p-12 flex flex-col items-center justify-center border-2 border-dashed border-gray-300 rounded-lg m-8 transition-all hover:border-indigo-400"
>
<FileOutlined className="text-5xl text-gray-400 mb-4" />
<h3 className="text-xl font-medium text-gray-700 mb-2">{t('translator.uploadFilePrompt')}</h3>
<Upload
name="document"
showUploadList={false}
customRequest={({ file, onSuccess }) => {
setTimeout(() => {
onSuccess("ok");
}, 0);
}}
beforeUpload={(file) => {
const isPdf = file.type === 'application/pdf';
if (!isPdf) {
message.error(t('documentTranslator.uploadPdfOnly'));
}
return isPdf;
}}
onChange={handleUpload}
>
<Button icon={<UploadOutlined />} size="large" type="primary" className="bg-indigo-600 hover:bg-indigo-700">
{t('translator.selectDocument')}
</Button>
</Upload>
{!isUserAuthenticated && (
<p className="text-amber-500 mt-4">
<LoginOutlined className="mr-1" />
{t('translator.loginRequired')}
</p>
)}
<p className="text-gray-400 mt-4 text-sm">{t('translator.supportsPdf')}</p>
</div>
) : (
// Existing document viewer and translator UI
<div className="flex flex-col md:flex-row h-[800px]">
{/* PDF viewer side */}
<div
ref={pdfContentRef}
className="w-full md:w-1/2 overflow-y-auto p-4 bg-gray-100 border-r border-gray-200"
style={{
height: '100%'
}}
>
{loading && !documentData && (
<div className="flex flex-col items-center justify-center h-full">
<Spin size="large" />
<p className="mt-4 text-gray-600">{t('documentTranslator.loading')}</p>
</div>
)}
</div>
{/* Translation side */}
<div
className="w-full md:w-1/2 overflow-y-auto p-4 bg-white"
style={{
height: '100%'
}}
>
{loading && !documentData ? (
<div className="flex flex-col items-center justify-center h-full">
<Spin size="large" />
<p className="mt-4 text-gray-600">{t('documentTranslator.translating')}</p>
</div>
) : documentData ? (
<div
ref={translationContentRef}
className="translation-content-container"
>
{renderTranslations()}
</div>
) : (
<div className="flex flex-col items-center justify-center h-full">
<Empty
description={t('documentTranslator.translationPlaceholder')}
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
</div>
)}
</div>
</div>
)}
</div>
</div>
</div>
<style jsx>{`
.pdf-page {
position: relative;
margin-bottom: 20px;
box-shadow: 0 0 10px rgba(0,0,0,0.3);
background-color: white;
}
.highlighted {
background-color: yellow !important;
opacity: 0.5;
}
.paragraph-block {
margin-bottom: 5px;
cursor: pointer;
box-sizing: border-box;
border: 1px solid transparent;
padding: 5px;
}
.paragraph-block.active {
border: 1px dashed #183bc8;
background-color: rgba(24, 59, 200, 0.05);
}
.translation-content-container img {
max-width: 100%;
height: auto;
}
/* Styles for math formulas */
.katex {
font-size: 1.1em;
}
`}</style>
</>
);
};
export default DocumentTranslatorContent;
\ No newline at end of file
......@@ -8,11 +8,34 @@ import logo from '/assets/logo.png';
function Header() {
const { t } = useTranslation();
const navigate = useNavigate();
const user = JSON.parse(localStorage.getItem('user'));
const [user, setUser] = useState(null);
const [menuOpen, setMenuOpen] = useState(false);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const menuRef = useRef(null);
// Check for user on component mount and localStorage changes
useEffect(() => {
const checkUser = () => {
try {
const userData = localStorage.getItem('user');
if (userData) {
setUser(JSON.parse(userData));
} else {
setUser(null);
}
} catch (error) {
console.error('Failed to parse user data');
setUser(null);
}
};
checkUser();
// Listen for storage events (in case user logs in/out in another tab)
window.addEventListener('storage', checkUser);
return () => window.removeEventListener('storage', checkUser);
}, []);
const toggleMenu = () => {
setMenuOpen(!menuOpen);
};
......@@ -32,6 +55,7 @@ function Header() {
const handleLogout = () => {
localStorage.removeItem('user');
setUser(null);
navigate('/login');
setMobileMenuOpen(false);
};
......@@ -330,6 +354,19 @@ function Header() {
>
翻译工具
</NavLink>
<NavLink
to="/document-translator"
className={({isActive}) =>
`block px-4 py-3 rounded-lg text-base font-medium transition-colors duration-200 ${
isActive
? 'bg-indigo-50 text-indigo-600'
: 'text-gray-700 hover:bg-gray-50 hover:text-indigo-600'
}`
}
onClick={() => setMobileMenuOpen(false)}
>
{t('documentTranslator.title', 'PDF Translator')}
</NavLink>
<NavLink
to="/ai-products"
className={({isActive}) =>
......
......@@ -19,8 +19,30 @@
"description": "A suite of practical image processing tools, including a handwriting font generator, Markdown to image converter, and quote to image creator, making image editing and creative design tasks easier."
},
"translator": {
"title": "Translator",
"description": "Supports text and image multi-language translation, making it easy to communicate across languages."
"title": "Translation Tool",
"description": "Supports multilingual translation for text and images, enabling easy cross-language communication.",
"tabs": {
"text": "Text Translation",
"image": "Image Translation",
"document": "Document Translation"
},
"targetLanguageLabel": "Target Language",
"textPlaceholder": "Please enter the text to be translated",
"translationPlaceholder": "Translation results will be displayed here",
"imagePlaceholder": "Please upload an image to be translated",
"translateButton": "Translate",
"uploadImageButton": "Upload Image",
"emptyImagePlaceholder": "Please upload an image first",
"displayModeLabel": "Display Mode",
"displayModes": {
"translation": "Translation",
"bilingual": "Bilingual",
"original": "Original"
},
"uploadFilePrompt": "Upload File",
"selectDocument": "Select File",
"loginRequired": "Please log in first",
"supportsPdf": "PDF files are supported"
},
"blog": {
"title": "AI News",
......
......@@ -20,7 +20,29 @@
},
"translator": {
"title": "翻訳ツール",
"description": "テキストと画像の多言語翻訳をサポートし、簡単に言語を越えて交流できます。"
"description": "テキストや画像の多言語翻訳に対応し、簡単に言語の壁を越えたコミュニケーションを実現します。",
"tabs": {
"text": "テキスト翻訳",
"image": "画像翻訳",
"document": "ドキュメント翻訳"
},
"targetLanguageLabel": "翻訳先の言語",
"textPlaceholder": "翻訳したいテキストを入力してください",
"translationPlaceholder": "翻訳結果はここに表示されます",
"imagePlaceholder": "翻訳する画像をアップロードしてください",
"translateButton": "翻訳",
"uploadImageButton": "画像をアップロード",
"emptyImagePlaceholder": "まず画像をアップロードしてください",
"displayModeLabel": "表示モード",
"displayModes": {
"translation": "翻訳",
"bilingual": "バイリンガル",
"original": "原文"
},
"uploadFilePrompt": "ファイルをアップロード",
"selectDocument": "ファイルを選択",
"loginRequired": "ログインしてください",
"supportsPdf": "PDFファイルに対応"
},
"blog": {
"title": "AIニュース",
......
......@@ -20,7 +20,29 @@
},
"translator": {
"title": "번역 도구",
"description": "텍스트 및 이미지 다국어 번역을 지원하여 교류를 쉽게 할 수 있습니다."
"description": "텍스트와 이미지의 다국어 번역을 지원하여 언어 장벽 없이 쉽게 소통할 수 있습니다.",
"tabs": {
"text": "텍스트 번역",
"image": "이미지 번역",
"document": "문서 번역"
},
"targetLanguageLabel": "대상 언어",
"textPlaceholder": "번역할 텍스트를 입력하세요",
"translationPlaceholder": "번역 결과가 여기에 표시됩니다",
"imagePlaceholder": "번역할 이미지를 업로드하세요",
"translateButton": "번역",
"uploadImageButton": "이미지 업로드",
"emptyImagePlaceholder": "먼저 이미지를 업로드해주세요",
"displayModeLabel": "표시 모드",
"displayModes": {
"translation": "번역",
"bilingual": "이중 언어",
"original": "원문"
},
"uploadFilePrompt": "파일 업로드",
"selectDocument": "파일 선택",
"loginRequired": "먼저 로그인해주세요",
"supportsPdf": "PDF 파일 지원"
},
"blog": {
"title": "AI 뉴스",
......
......@@ -20,7 +20,29 @@
},
"translator": {
"title": "翻译工具",
"description": "支持文本和图片多语言翻译,轻松实现跨语言交流。"
"description": "支持文本和图片多语言翻译,轻松实现跨语言交流。",
"tabs": {
"text": "文本翻译",
"image": "图片翻译",
"document": "文档翻译"
},
"targetLanguageLabel": "目标语言",
"textPlaceholder": "请输入要翻译的文本",
"translationPlaceholder": "翻译结果将显示在这里",
"imagePlaceholder": "请上传要翻译的图片",
"translateButton": "翻译",
"uploadImageButton": "上传图片",
"emptyImagePlaceholder": "请先上传图片",
"displayModeLabel": "显示模式",
"displayModes": {
"translation": "翻译",
"bilingual": "双语",
"original": "原文"
},
"uploadFilePrompt": "上传文件",
"selectDocument": "选择文件",
"loginRequired": "请先登录",
"supportsPdf": "支持PDF文件"
},
"blog": {
"title": "AI 资讯",
......
import React, { useState, useRef, useEffect } from 'react';
import { Select, Button, Spin, Tooltip, Empty, Upload, message } from 'antd';
import {
UploadOutlined,
SoundOutlined,
CopyOutlined,
InfoCircleOutlined,
GlobalOutlined,
FileOutlined,
FileImageOutlined,
SyncOutlined,
PauseOutlined
} from '@ant-design/icons';
import { useTranslation } from '../js/i18n';
import SEO from '../components/SEO';
// Import PDF.js
// Note: In a real implementation, you might want to properly set up PDF.js with its worker
const pdfjsLib = window.pdfjsLib;
if (pdfjsLib) {
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/2.9.359/pdf.worker.min.js';
}
// API configuration - reusing the same API as the Translator component
const API_KEY = '28c81f920240b0fdbca940e07b86b8db';
const API_SECRET = 'd6e57784b134d09a8bed9ca004c98b4f';
const API_BASE_URL = 'https://www.heytransl.com';
const API_DOCUMENT_URL = `${API_BASE_URL}/api/translate/document`;
/**
* Generate authentication headers
* @param {string} apiKey - API key
* @param {string} apiSecret - API secret
* @param {object} body - Request body
* @returns {Promise<object>} - Object containing authentication headers
*/
function generateHeaders(apiKey, apiSecret, body) {
const timestamp = Math.floor(Date.now() / 1000).toString();
const bodyStr = JSON.stringify(body);
const messageToSign = `${apiKey}${timestamp}${bodyStr}`;
// Generate HMAC SHA-256 signature
const encoder = new TextEncoder();
const key = encoder.encode(apiSecret);
const message = encoder.encode(messageToSign);
return crypto.subtle.importKey(
'raw',
key,
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
).then(key => {
return crypto.subtle.sign(
'HMAC',
key,
message
);
}).then(signature => {
const hashArray = Array.from(new Uint8Array(signature));
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
return {
'X-API-Key': apiKey,
'X-Timestamp': timestamp,
'X-Signature': hashHex,
'Content-Type': 'application/json',
'Accept': 'text/event-stream'
};
});
}
/**
* Convert file to base64 string
* @param {File} file - File object
* @returns {Promise<string>} - base64 string
*/
function fileToBase64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => resolve(reader.result);
reader.onerror = error => reject(error);
});
}
/**
* Extract translation content from SSE response
* @param {object} data - Parsed JSON object
* @returns {string|null} - Extracted translation content or null
*/
function extractTranslationContent(data) {
if (!data) return null;
try {
// Handle various response formats
if (data.document_translation) {
return data.document_translation;
}
if (data.translation_progress) {
return data.translation_progress;
}
// For compatibility with existing extractTranslationContent function
if (data.choices && data.choices.length > 0) {
if (data.choices[0].delta && data.choices[0].delta.content !== undefined) {
return data.choices[0].delta.content || '';
}
if (data.choices[0].message && data.choices[0].message.content !== undefined) {
return data.choices[0].message.content || '';
}
if (data.choices[0].text !== undefined) {
return data.choices[0].text || '';
}
}
// Handle other possible response formats
if (data.translated_text !== undefined) {
return data.translated_text || '';
}
if (data.content !== undefined) {
return data.content || '';
}
if (data.text !== undefined) {
return data.text || '';
}
console.warn('Unknown translation data structure:', data);
return '';
} catch (e) {
console.error('Failed to extract translation content:', e, data);
return '';
}
}
/**
* Parse SSE event data
* @param {string} data - SSE event data
* @returns {object|null} - Parsed JSON object, or null
*/
function parseSSEData(data) {
if (!data) return null;
if (data === '[DONE]') return null;
let jsonData = data;
if (typeof data === 'string') {
if (data.startsWith('data:')) {
jsonData = data.substring(5).trim();
}
if (jsonData === '[DONE]') return null;
try {
return JSON.parse(jsonData);
} catch (e) {
if (jsonData.trim()) {
return { text: jsonData.trim() };
}
return null;
}
} else if (typeof data === 'object') {
return data;
}
return null;
}
/**
* Process SSE stream
* @param {Response} response - Fetch response object
* @param {function} onData - Callback for processing each data chunk
* @param {function} onDone - Callback for when processing is complete
* @param {function} onError - Callback for errors
*/
async function processSSEStream(response, onData, onDone, onError) {
if (!response.ok) {
onError(new Error(`HTTP error: ${response.status}`));
return;
}
if (!response.body) {
onError(new Error('Response has no readable data stream'));
return;
}
try {
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) {
if (buffer.trim()) {
const lines = buffer.split('\n');
for (const line of lines) {
if (line.startsWith('data:')) {
const data = line.substring(5).trim();
if (data === '[DONE]') {
onDone();
return;
}
const parsedData = parseSSEData(data);
if (parsedData) onData(parsedData);
}
}
}
break;
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data:')) {
const data = line.substring(5).trim();
if (data === '[DONE]') {
onDone();
return;
}
const parsedData = parseSSEData(data);
if (parsedData) onData(parsedData);
}
}
}
onDone();
} catch (error) {
onError(error);
}
}
const DocumentTranslator = () => {
const { t } = useTranslation();
const [targetLanguage, setTargetLanguage] = useState('中文');
const [loading, setLoading] = useState(false);
const [currentDocument, setCurrentDocument] = useState(null);
const [pdfInstance, setPdfInstance] = useState(null);
const [pdfScale, setPdfScale] = useState(1);
const [documentData, setDocumentData] = useState(null);
const [displayMode, setDisplayMode] = useState('translation'); // 'translation', 'bilingual', 'original'
const [activeBlock, setActiveBlock] = useState(null);
const pdfContentRef = useRef(null);
const translationContentRef = useRef(null);
const fileInputRef = useRef(null);
const dropAreaRef = useRef(null);
const abortControllerRef = useRef(null);
const languages = [
{ value: '中文', label: '中文 (Chinese)' },
{ value: 'English', label: 'English' },
{ value: '日本語', label: '日本語 (Japanese)' },
{ value: '한국어', label: '한국어 (Korean)' },
{ value: 'Español', label: 'Español (Spanish)' },
{ value: 'Français', label: 'Français (French)' },
{ value: 'Deutsch', label: 'Deutsch (German)' },
{ value: 'Русский', label: 'Русский (Russian)' },
{ value: 'Português', label: 'Português (Portuguese)' },
{ value: 'Italiano', label: 'Italiano (Italian)' },
{ value: 'العربية', label: 'العربية (Arabic)' },
];
// Cleanup on component unmount
useEffect(() => {
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, []);
// Setup file drop event handlers
useEffect(() => {
// Clipboard paste handler
const handlePaste = (e) => {
if (e.clipboardData && e.clipboardData.items) {
const items = e.clipboardData.items;
for (const item of items) {
if (item.type.indexOf('application/pdf') !== -1) {
const file = item.getAsFile();
handleDocumentFile(file);
message.success('Document added from clipboard');
break;
}
}
}
};
// Drag and drop handlers
const handleDragOver = (e) => {
e.preventDefault();
if (dropAreaRef.current) {
dropAreaRef.current.classList.add('border-indigo-500');
}
};
const handleDragLeave = (e) => {
e.preventDefault();
if (dropAreaRef.current) {
dropAreaRef.current.classList.remove('border-indigo-500');
}
};
const handleDrop = (e) => {
e.preventDefault();
if (dropAreaRef.current) {
dropAreaRef.current.classList.remove('border-indigo-500');
}
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
const file = e.dataTransfer.files[0];
if (file.type !== 'application/pdf') {
message.error('Please upload a PDF file');
return;
}
handleDocumentFile(file);
message.success('Document added');
}
};
document.addEventListener('paste', handlePaste);
const dropArea = dropAreaRef.current;
if (dropArea) {
dropArea.addEventListener('dragover', handleDragOver);
dropArea.addEventListener('dragleave', handleDragLeave);
dropArea.addEventListener('drop', handleDrop);
}
return () => {
document.removeEventListener('paste', handlePaste);
if (dropArea) {
dropArea.removeEventListener('dragover', handleDragOver);
dropArea.removeEventListener('dragleave', handleDragLeave);
dropArea.removeEventListener('drop', handleDrop);
}
};
}, []);
// Handle window resize for PDF scale
useEffect(() => {
const handleResize = debounce(() => {
if (currentDocument) {
loadPDF(currentDocument.url);
}
}, 250);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [currentDocument]);
// Helper function for debouncing
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
const handleDocumentFile = (file) => {
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
setCurrentDocument({
uid: Date.now(),
name: file.name,
status: 'done',
url: event.target.result,
originFileObj: file
});
// Load PDF and translate
loadPDF(event.target.result);
translateDocument(file, targetLanguage);
};
reader.readAsDataURL(file);
};
const translateDocument = async (file, language) => {
if (!file) {
message.warning('Please upload a document first');
return;
}
// Abort previous request if any
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
// Create new AbortController
abortControllerRef.current = new AbortController();
const { signal } = abortControllerRef.current;
setLoading(true);
setDocumentData(null);
try {
// Convert document to base64
const base64Document = await fileToBase64(file);
// Build request body
const requestBody = {
document_base64: base64Document,
document_name: file.name,
target_language: language || targetLanguage,
stream: true
};
// Generate auth headers
const headers = await generateHeaders(
API_KEY,
API_SECRET,
requestBody
);
const response = await fetch(API_DOCUMENT_URL, {
method: 'POST',
headers,
body: JSON.stringify(requestBody),
signal
});
let translationResult = null;
// Process SSE stream
await processSSEStream(
response,
// Data callback
(data) => {
const content = extractTranslationContent(data);
if (content) {
if (typeof content === 'object') {
// For structured translation data
translationResult = content;
setDocumentData(content);
} else if (data.translation_progress) {
// Handle progress updates if needed
console.log('Translation progress:', data.translation_progress);
}
}
},
// Complete callback
() => {
setLoading(false);
if (translationResult) {
setDocumentData(translationResult);
}
},
// Error callback
(error) => {
if (error.name !== 'AbortError') {
console.error('Document translation error:', error);
message.error('Document translation service temporarily unavailable');
// For development testing - use mock data
if (process.env.NODE_ENV === 'development') {
setTimeout(() => {
// Mock data structure
setDocumentData({
pages: [
{
page_idx: 0,
para_blocks: [
{
type: 'title',
text: 'Document Translation Example',
translation: '文档翻译示例',
bbox: [50, 50, 500, 100]
},
{
type: 'text',
text: 'This is an example of document translation with immersive bilingual reading.',
translation: '这是一个带有沉浸式双语阅读的文档翻译示例。',
bbox: [50, 150, 500, 200]
}
]
}
]
});
setLoading(false);
}, 1500);
} else {
setLoading(false);
}
} else {
setLoading(false);
}
}
);
} catch (error) {
if (error.name !== 'AbortError') {
console.error('Document translation error:', error);
message.error('Document translation service temporarily unavailable');
// For development testing
if (process.env.NODE_ENV === 'development') {
setTimeout(() => {
// Add mock data here similar to above
setLoading(false);
}, 1500);
} else {
setLoading(false);
}
} else {
setLoading(false);
}
}
};
const loadPDF = async (url) => {
if (!pdfjsLib) {
message.error('PDF.js library not loaded. Please refresh the page.');
return;
}
try {
// Clear previous content
if (pdfContentRef.current) {
pdfContentRef.current.innerHTML = '';
}
const loadingTask = pdfjsLib.getDocument(url);
const pdf = await loadingTask.promise;
setPdfInstance(pdf);
const pdfContainer = pdfContentRef.current;
if (!pdfContainer) return;
for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) {
const page = await pdf.getPage(pageNum);
const originalViewport = page.getViewport({ scale: 1 });
const containerWidth = pdfContainer.clientWidth - 40;
const scale = containerWidth / originalViewport.width;
setPdfScale(scale);
const dpr = window.devicePixelRatio || 2;
const scaledViewport = page.getViewport({ scale: scale * dpr });
const pageDiv = document.createElement('div');
pageDiv.className = 'pdf-page';
pageDiv.setAttribute('data-page-number', pageNum);
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
canvas.height = scaledViewport.height;
canvas.width = scaledViewport.width;
canvas.style.width = `${scaledViewport.width / dpr}px`;
canvas.style.height = `${scaledViewport.height / dpr}px`;
const renderContext = {
canvasContext: context,
viewport: scaledViewport
};
pageDiv.appendChild(canvas);
pdfContainer.appendChild(pageDiv);
await page.render(renderContext);
}
} catch (error) {
console.error('Error loading PDF:', error);
message.error('Failed to load PDF document');
}
};
const renderTranslations = () => {
if (!documentData || !documentData.pages) return null;
return documentData.pages.map((page, pageIndex) => {
const pageNumber = page.page_idx + 1;
return page.para_blocks.map((paraBlock, blockIndex) => {
// Create paragraph block
const paraDiv = (
<div
key={`${pageNumber}-${blockIndex}`}
className={`paragraph-block ${activeBlock === `${pageNumber}-${blockIndex}` ? 'active' : ''}`}
onClick={() => {
setActiveBlock(`${pageNumber}-${blockIndex}`);
highlightOriginalText(pageNumber, paraBlock.bbox);
}}
>
{/* Original text block */}
{(paraBlock.text || paraBlock.type === 'image' || paraBlock.type === 'table') && (
<div
className="origin-block"
style={{
display: displayMode === 'original' || displayMode === 'bilingual' ? 'block' : 'none',
fontFamily: '"Merriweather", Georgia, serif',
fontSize: '16px',
lineHeight: 1.8,
color: '#555',
padding: '5px'
}}
>
{renderParaBlock(paraBlock, 'original')}
</div>
)}
{/* Translation block */}
{(paraBlock.translation || paraBlock.type === 'image' || paraBlock.type === 'table') && (
<div
className="translation-block"
style={{
display: displayMode === 'translation' || displayMode === 'bilingual' ? 'block' : 'none',
fontFamily: '"Noto Sans SC", "Microsoft YaHei", Arial, sans-serif',
fontSize: '18px',
lineHeight: 1.6,
color: '#333',
padding: '5px',
backgroundColor: displayMode === 'bilingual' ? '#f3f4f6' : 'transparent'
}}
>
{renderParaBlock(paraBlock, 'translation')}
</div>
)}
</div>
);
return paraDiv;
});
});
};
const renderParaBlock = (paraBlock, mode) => {
const isOriginal = mode === 'original';
const content = isOriginal ? paraBlock.text : paraBlock.translation;
switch (paraBlock.type) {
case 'title':
return <h1>{content}</h1>;
case 'text':
return <p>{content}</p>;
case 'image':
// Render image with caption
return (
<div className="image-block">
{paraBlock.image_url && <img src={paraBlock.image_url} alt="Document image" />}
{paraBlock.caption && <p className="image-caption">{isOriginal ? paraBlock.caption : paraBlock.caption_translation}</p>}
</div>
);
case 'table':
// Render table with caption
return (
<div className="table-block">
{paraBlock.table_html && <div dangerouslySetInnerHTML={{ __html: paraBlock.table_html }} />}
{paraBlock.caption && <p className="table-caption">{isOriginal ? paraBlock.caption : paraBlock.caption_translation}</p>}
</div>
);
default:
return <p>{content}</p>;
}
};
const highlightOriginalText = (pageNumber, bbox) => {
// Remove previous highlights
document.querySelectorAll('.highlighted').forEach(el => {
el.parentElement.removeChild(el);
});
// Find corresponding page
const pageDiv = document.querySelector(`.pdf-page[data-page-number="${pageNumber}"]`);
if (!pageDiv) return;
// Create highlight element
const [x1, y1, x2, y2] = bbox;
const highlightEl = document.createElement('div');
highlightEl.className = 'highlighted';
highlightEl.style.position = 'absolute';
highlightEl.style.left = `${x1 * pdfScale}px`;
highlightEl.style.top = `${y1 * pdfScale}px`;
highlightEl.style.width = `${(x2 - x1) * pdfScale}px`;
highlightEl.style.height = `${(y2 - y1) * pdfScale}px`;
highlightEl.style.backgroundColor = 'yellow';
highlightEl.style.opacity = '0.5';
pageDiv.appendChild(highlightEl);
// Scroll to highlight
pageDiv.scrollIntoView({ behavior: 'smooth', block: 'center' });
};
const handleUpload = (info) => {
const { file } = info;
if (file.status !== 'uploading') {
handleDocumentFile(file.originFileObj);
}
};
const removeDocument = () => {
setCurrentDocument(null);
setDocumentData(null);
// Clear PDF container
if (pdfContentRef.current) {
pdfContentRef.current.innerHTML = '';
}
// Clear translation container
if (translationContentRef.current) {
translationContentRef.current.innerHTML = '';
}
};
const copyToClipboard = (text) => {
navigator.clipboard.writeText(text).then(() => {
message.success('Copied to clipboard');
}, (err) => {
message.error('Copy failed');
console.error('Copy failed: ', err);
});
};
return (
<>
<SEO
title={t('documentTranslator.title', 'Document Translator')}
description={t('documentTranslator.description', 'Translate documents with immersive bilingual reading')}
/>
<div className="flex flex-col items-center pt-16 md:pt-20 pb-12 px-4 sm:px-6">
<div className="w-full max-w-6xl">
<div className="bg-white rounded-xl shadow-lg overflow-hidden border border-gray-100">
{/* Header section */}
<div className="bg-gray-50 p-4 border-b border-gray-200">
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex items-center">
<GlobalOutlined className="text-indigo-500 mr-2" />
<span className="font-medium mr-2">Target Language:</span>
<Select
value={targetLanguage}
onChange={(value) => {
setTargetLanguage(value);
// Retranslate if document exists
if (currentDocument?.originFileObj) {
translateDocument(currentDocument.originFileObj, value);
}
}}
options={languages}
style={{ width: 180 }}
dropdownStyle={{ zIndex: 1001 }}
/>
</div>
<div className="flex items-center">
<span className="font-medium mr-2">Display Mode:</span>
<Select
value={displayMode}
onChange={setDisplayMode}
options={[
{ value: 'translation', label: 'Translation Only' },
{ value: 'bilingual', label: 'Bilingual' },
{ value: 'original', label: 'Original Only' }
]}
style={{ width: 180 }}
dropdownStyle={{ zIndex: 1001 }}
/>
</div>
{!currentDocument && (
<div className="text-sm text-gray-500 flex items-center">
<InfoCircleOutlined className="mr-1" />
<span>Upload PDF documents for immersive bilingual reading</span>
</div>
)}
{currentDocument && (
<div className="flex items-center">
<span className="text-gray-500 mr-2">{currentDocument.name}</span>
<Button
type="text"
danger
icon={<UploadOutlined />}
onClick={() => fileInputRef.current?.click()}
>
Change Document
</Button>
<input
type="file"
ref={fileInputRef}
onChange={(e) => {
if (e.target.files && e.target.files[0]) {
const file = e.target.files[0];
if (file.type === 'application/pdf') {
handleDocumentFile(file);
} else {
message.error('Please upload a PDF file');
}
}
e.target.value = '';
}}
style={{ display: 'none' }}
accept="application/pdf"
/>
</div>
)}
</div>
</div>
{/* Main content area */}
{!currentDocument ? (
<div
ref={dropAreaRef}
className="p-12 flex flex-col items-center justify-center border-2 border-dashed border-gray-300 rounded-lg m-8 transition-all hover:border-indigo-400"
>
<FileOutlined className="text-5xl text-gray-400 mb-4" />
<h3 className="text-xl font-medium text-gray-700 mb-2">Upload Your Document</h3>
<p className="text-gray-500 mb-6 text-center">Drag & drop your PDF here, or click to browse</p>
<Upload
name="document"
showUploadList={false}
customRequest={({ file, onSuccess }) => {
setTimeout(() => {
onSuccess("ok");
}, 0);
}}
beforeUpload={(file) => {
const isPdf = file.type === 'application/pdf';
if (!isPdf) {
message.error('You can only upload PDF files!');
}
return isPdf;
}}
onChange={handleUpload}
>
<Button icon={<UploadOutlined />} size="large" type="primary" className="bg-indigo-600 hover:bg-indigo-700">
Select Document
</Button>
</Upload>
<p className="text-gray-400 mt-4 text-sm">Supports PDF documents</p>
</div>
) : (
<div className="flex flex-col md:flex-row h-[800px]">
{/* PDF viewer side */}
<div
ref={pdfContentRef}
className="w-full md:w-1/2 overflow-y-auto p-4 bg-gray-100 border-r border-gray-200"
style={{
height: '100%'
}}
>
{loading && !documentData && (
<div className="flex flex-col items-center justify-center h-full">
<Spin size="large" />
<p className="mt-4 text-gray-600">Loading document...</p>
</div>
)}
</div>
{/* Translation side */}
<div
className="w-full md:w-1/2 overflow-y-auto p-4 bg-white"
style={{
height: '100%'
}}
>
{loading && !documentData ? (
<div className="flex flex-col items-center justify-center h-full">
<Spin size="large" />
<p className="mt-4 text-gray-600">Translating document...</p>
</div>
) : documentData ? (
<div
ref={translationContentRef}
className="translation-content-container"
>
{renderTranslations()}
</div>
) : (
<div className="flex flex-col items-center justify-center h-full">
<Empty
description="Translation will appear here"
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
</div>
)}
</div>
</div>
)}
</div>
</div>
</div>
<style jsx>{`
.pdf-page {
position: relative;
margin-bottom: 20px;
box-shadow: 0 0 10px rgba(0,0,0,0.3);
background-color: white;
}
.highlighted {
background-color: yellow !important;
opacity: 0.5;
}
.paragraph-block {
margin-bottom: 5px;
cursor: pointer;
box-sizing: border-box;
border: 1px solid transparent;
padding: 5px;
}
.paragraph-block.active {
border: 1px dashed #183bc8;
background-color: rgba(24, 59, 200, 0.05);
}
.translation-content-container img {
max-width: 100%;
height: auto;
}
/* Styles for math formulas */
.katex {
font-size: 1.1em;
}
`}</style>
</>
);
};
export default DocumentTranslator;
\ No newline at end of file
......@@ -7,6 +7,8 @@ const tools = [
{ id: 'handwrite', icon: '/assets/icon/handwrite.png', path: '/handwriting' },
{ id: 'quoteCard', icon: '/assets/icon/quotecard.png', path: '/quote-card' },
{ id: 'markdown2image', icon: '/assets/icon/markdown2image.png', path: '/markdown-to-image' },
{ id: 'translator', icon: '/assets/icon/translator.png', path: '/translator' },
{ id: 'documentTranslator', icon: '/assets/icon/pdf-translator.png', path: '/document-translator' },
{ id: 'wechatFormatter', icon: '/assets/icon/editor.png', path: '/wechat-formatter' },
{ id: 'perpetualCalendar', icon: '/assets/icon/calendar.jpg', path: '/perpetual-calendar' },
{ id: 'imageAnnotator', icon: '/assets/icon/image-annotator.png', path: '/image-annotator' },
......
......@@ -14,8 +14,21 @@ const Login = () => {
const base64Url = credential.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const decodedPayload = JSON.parse(window.atob(base64));
// Save user data to localStorage
localStorage.setItem('user', JSON.stringify(decodedPayload));
navigate('/');
// Check if there's a redirect path saved
const redirectPath = sessionStorage.getItem('redirectAfterLogin');
// 使用直接的 window.location 跳转而不是 React Router 导航
// 这样可以确保页面完全重新加载,刷新登录状态
if (redirectPath) {
sessionStorage.removeItem('redirectAfterLogin');
window.location.href = redirectPath;
} else {
window.location.href = '/';
}
};
const handleLoginError = () => {
......@@ -23,9 +36,17 @@ const Login = () => {
};
useEffect(() => {
const user = localStorage.getItem('user');
if (user) {
navigate('/');
// Redirect if user is already logged in
const userData = localStorage.getItem('user');
if (userData) {
// Check if there's a redirect path saved
const redirectPath = sessionStorage.getItem('redirectAfterLogin');
if (redirectPath) {
sessionStorage.removeItem('redirectAfterLogin');
window.location.href = redirectPath;
} else {
window.location.href = '/';
}
}
}, [navigate]);
......
import React, { useState, useRef, useEffect } from 'react';
import React, { useState, useRef, useEffect, lazy, Suspense } from 'react';
import { Select, Button, Tabs, Input, Upload, message, Spin, Tooltip, Empty, Card, Space, Typography } from 'antd';
import {
UploadOutlined,
......@@ -14,11 +14,15 @@ import {
GlobalOutlined,
FileImageOutlined,
SyncOutlined,
PauseOutlined
PauseOutlined,
FileOutlined
} from '@ant-design/icons';
import { useTranslation } from '../js/i18n';
import SEO from '../components/SEO';
// Lazy load the DocumentTranslator component
const DocumentTranslatorContent = lazy(() => import('../components/DocumentTranslatorContent'));
const { TabPane } = Tabs;
const { TextArea } = Input;
const { Title, Paragraph, Text } = Typography;
......@@ -329,7 +333,7 @@ const Translator = () => {
};
reader.readAsDataURL(file);
message.success('已从剪贴板添加图片');
message.success(t('translator.imageAddedFromClipboard'));
break;
}
}
......@@ -361,7 +365,7 @@ const Translator = () => {
const file = e.dataTransfer.files[0];
if (!file.type.startsWith('image/')) {
message.error('请上传图片文件');
message.error(t('translator.uploadImageOnly'));
return;
}
......@@ -381,7 +385,7 @@ const Translator = () => {
};
reader.readAsDataURL(file);
message.success('已添加图片');
message.success(t('translator.imageAdded'));
}
};
......@@ -421,7 +425,7 @@ const Translator = () => {
const translateText = async () => {
if (!sourceText.trim()) {
message.warning('请输入要翻译的文本');
message.warning(t('translator.enterTextPrompt'));
return;
}
......@@ -479,7 +483,7 @@ const Translator = () => {
(error) => {
if (error.name !== 'AbortError') {
console.error('Translation error:', error);
message.error('翻译服务暂时不可用');
message.error(t('translator.serviceUnavailable'));
}
setLoading(false);
setStreamingTranslation(false);
......@@ -488,7 +492,7 @@ const Translator = () => {
} catch (error) {
if (error.name !== 'AbortError') {
console.error('Translation error:', error);
message.error('翻译服务暂时不可用');
message.error(t('translator.serviceUnavailable'));
}
setLoading(false);
setStreamingTranslation(false);
......@@ -497,7 +501,7 @@ const Translator = () => {
const translateImage = async (file, newTargetLanguage) => {
if (!file) {
message.warning('请先上传图片');
message.warning(t('translator.uploadImageFirst'));
return;
}
......@@ -563,7 +567,7 @@ const Translator = () => {
(error) => {
if (error.name !== 'AbortError') {
console.error('Image translation error:', error);
message.error('图片翻译服务暂时不可用');
message.error(t('translator.imageServiceUnavailable'));
// For demo purposes only. In production, you should remove this section
if (process.env.NODE_ENV === 'development') {
......@@ -586,7 +590,7 @@ const Translator = () => {
} catch (error) {
if (error.name !== 'AbortError') {
console.error('Image translation error:', error);
message.error('图片翻译服务暂时不可用');
message.error(t('translator.imageServiceUnavailable'));
// For demo purposes only. In production, you should remove this section
if (process.env.NODE_ENV === 'development') {
......@@ -633,16 +637,16 @@ const Translator = () => {
const copyToClipboard = (text) => {
navigator.clipboard.writeText(text).then(() => {
message.success('已复制到剪贴板');
message.success(t('translator.copied'));
}, (err) => {
message.error('复制失败');
message.error(t('translator.copyFailed'));
console.error('复制失败: ', err);
});
};
const playText = (text) => {
if (!text) {
message.warning('没有可朗读的文本');
message.warning(t('translator.noTextToSpeak'));
return;
}
......@@ -683,7 +687,7 @@ const Translator = () => {
label: (
<span className="flex items-center gap-2">
<TranslationOutlined />
<span>文本翻译</span>
<span>{t('translator.tabs.text')}</span>
</span>
),
children: (
......@@ -691,7 +695,7 @@ const Translator = () => {
<div className="flex items-center bg-gray-50 rounded-t-lg p-4 border-b border-gray-200">
<div className="flex items-center mr-2">
<GlobalOutlined className="text-indigo-500 mr-2" />
<span className="font-medium">目标语言:</span>
<span className="font-medium">{t('translator.targetLanguageLabel')}</span>
</div>
<Select
value={targetLanguage}
......@@ -700,10 +704,6 @@ const Translator = () => {
style={{ width: 180 }}
dropdownStyle={{ zIndex: 1001 }}
/>
<div className="ml-auto text-sm text-gray-500 flex items-center">
<InfoCircleOutlined className="mr-1" />
<span>输入任意语言文本,自动翻译为所选语言</span>
</div>
</div>
<div className="flex flex-col md:flex-row">
......@@ -711,7 +711,7 @@ const Translator = () => {
<TextArea
value={sourceText}
onChange={(e) => setSourceText(e.target.value)}
placeholder="请输入要翻译的文本"
placeholder={t('translator.textPlaceholder')}
autoSize={{ minRows: 12, maxRows: 20 }}
className="border-none rounded-none !shadow-none focus:shadow-none"
disabled={loading}
......@@ -719,7 +719,7 @@ const Translator = () => {
{sourceText && (
<div className="absolute bottom-3 right-3 flex space-x-2">
<Tooltip title={isSpeaking && window.speechSynthesis.speaking ? "停止播放" : "播放原文"}>
<Tooltip title={isSpeaking && window.speechSynthesis.speaking ? t('translator.tooltip.stopPlayback') : t('translator.tooltip.playSource')}>
<Button
type="text"
icon={isSpeaking && window.speechSynthesis.speaking ? <PauseOutlined /> : <SoundOutlined />}
......@@ -728,7 +728,7 @@ const Translator = () => {
className="text-gray-500 hover:text-indigo-500"
/>
</Tooltip>
<Tooltip title="复制原文">
<Tooltip title={t('translator.tooltip.copySource')}>
<Button
type="text"
icon={<CopyOutlined />}
......@@ -746,21 +746,21 @@ const Translator = () => {
<div className="absolute inset-0 flex items-center justify-center bg-white bg-opacity-70 z-10">
<div className="flex flex-col items-center">
<Spin size="large" />
<span className="mt-4 text-gray-600">正在翻译...</span>
<span className="mt-4 text-gray-600">{t('translator.translating')}</span>
</div>
</div>
) : null}
<TextArea
value={translatedText}
readOnly
placeholder="翻译结果将显示在这里"
placeholder={t('translator.translationPlaceholder')}
autoSize={{ minRows: 12, maxRows: 20 }}
className={`border-none rounded-none !shadow-none bg-gray-50 ${streamingTranslation ? 'streaming-translation' : ''}`}
/>
{translatedText && (
<div className="absolute bottom-3 right-3 flex space-x-2">
<Tooltip title={isSpeaking && window.speechSynthesis.speaking ? "停止播放" : "播放译文"}>
<Tooltip title={isSpeaking && window.speechSynthesis.speaking ? t('translator.tooltip.stopPlayback') : t('translator.tooltip.playTranslation')}>
<Button
type="text"
icon={isSpeaking && window.speechSynthesis.speaking ? <PauseOutlined /> : <SoundOutlined />}
......@@ -769,7 +769,7 @@ const Translator = () => {
className="text-gray-500 hover:text-indigo-500"
/>
</Tooltip>
<Tooltip title="复制译文">
<Tooltip title={t('translator.tooltip.copyTranslation')}>
<Button
type="text"
icon={<CopyOutlined />}
......@@ -792,7 +792,7 @@ const Translator = () => {
className="bg-indigo-600 hover:bg-indigo-700 px-8"
size="large"
>
翻译
{t('translator.translateButton')}
</Button>
</div>
</div>
......@@ -803,7 +803,7 @@ const Translator = () => {
label: (
<span className="flex items-center gap-2">
<PictureOutlined />
<span>图片翻译</span>
<span>{t('translator.tabs.image')}</span>
</span>
),
children: (
......@@ -812,7 +812,7 @@ const Translator = () => {
<div className="flex items-center mb-3">
<div className="flex items-center mr-2">
<GlobalOutlined className="text-indigo-500 mr-2" />
<span className="font-medium">目标语言:</span>
<span className="font-medium">{t('translator.targetLanguageLabel')}</span>
</div>
<Select
value={targetLanguage}
......@@ -827,10 +827,6 @@ const Translator = () => {
style={{ width: 180 }}
dropdownStyle={{ zIndex: 1001 }}
/>
<div className="ml-auto text-sm text-gray-500 flex items-center">
<InfoCircleOutlined className="mr-1" />
<span>支持拖拽和粘贴图片 (Ctrl+V)</span>
</div>
</div>
</div>
......@@ -851,12 +847,12 @@ const Translator = () => {
className="absolute inset-0 cursor-pointer bg-black bg-opacity-0 group-hover:bg-opacity-20 transition-all flex items-center justify-center opacity-0 group-hover:opacity-100"
onClick={() => fileInputRef.current?.click()}
>
<span className="text-white bg-black bg-opacity-50 px-3 py-1 rounded-full text-sm">点击更换图片</span>
<span className="text-white bg-black bg-opacity-50 px-3 py-1 rounded-full text-sm">{t('translator.replaceImage')}</span>
</div>
{/* Delete button - positioned outside the clickable area */}
<div className="absolute top-2 right-2 z-20">
<Tooltip title="删除图片">
<Tooltip title={t('translator.tooltip.deleteImage')}>
<Button
type="default"
size="small"
......@@ -891,7 +887,7 @@ const Translator = () => {
};
reader.readAsDataURL(file);
} else {
message.error('您只能上传图片文件!');
message.error(t('translator.uploadImageOnlyError'));
}
}
// Clear the input value to allow selecting the same file again
......@@ -914,7 +910,7 @@ const Translator = () => {
beforeUpload={(file) => {
const isImage = file.type.startsWith('image/');
if (!isImage) {
message.error('您只能上传图片文件!');
message.error(t('translator.uploadImageOnlyError'));
}
return isImage;
}}
......@@ -923,7 +919,7 @@ const Translator = () => {
>
<div className="flex flex-col items-center justify-center">
<FileImageOutlined className="text-4xl text-gray-400" />
<p className="text-gray-500 mt-2">上传图片</p>
<p className="text-gray-500 mt-2">{t('translator.uploadImageButton')}</p>
</div>
</Upload>
)}
......@@ -943,7 +939,7 @@ const Translator = () => {
{loading && !streamingTranslation ? (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-white bg-opacity-70 z-10">
<Spin size="large" />
<span className="mt-4 text-gray-600">图片翻译中...</span>
<span className="mt-4 text-gray-600">{t('translator.imageTranslating')}</span>
</div>
) : currentImage ? (
<div className="h-full relative">
......@@ -951,14 +947,14 @@ const Translator = () => {
<TextArea
value={translatedText}
readOnly
placeholder="翻译结果将显示在这里"
placeholder={t('translator.translationPlaceholder')}
autoSize={{ minRows: 12, maxRows: 20 }}
className={`border-none rounded-none !shadow-none bg-gray-50 ${streamingTranslation ? 'streaming-translation' : ''}`}
/>
) : (
<div className="h-full flex items-center justify-center">
<Empty
description="尚未有翻译结果,请尝试重新翻译"
description={t('translator.emptyImageResult')}
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
</div>
......@@ -967,7 +963,7 @@ const Translator = () => {
) : (
<div className="h-full flex items-center justify-center">
<Empty
description="上传图片后,识别的文本将显示在这里"
description={t('translator.emptyImagePlaceholder')}
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
</div>
......@@ -979,7 +975,7 @@ const Translator = () => {
<div className="px-4 py-2 bg-gray-100 border-t border-gray-200 flex justify-end space-x-2">
{translatedText && (
<>
<Tooltip title={isSpeaking && window.speechSynthesis.speaking ? "停止播放" : "播放译文"}>
<Tooltip title={isSpeaking && window.speechSynthesis.speaking ? t('translator.tooltip.stopPlayback') : t('translator.tooltip.playTranslation')}>
<Button
type="text"
icon={isSpeaking && window.speechSynthesis.speaking ? <PauseOutlined /> : <SoundOutlined />}
......@@ -988,7 +984,7 @@ const Translator = () => {
className="text-gray-500 hover:text-indigo-500"
/>
</Tooltip>
<Tooltip title="复制译文">
<Tooltip title={t('translator.tooltip.copyTranslation')}>
<Button
type="text"
icon={<CopyOutlined />}
......@@ -1000,7 +996,7 @@ const Translator = () => {
</>
)}
{!loading && currentImage?.originFileObj && (
<Tooltip title="重新翻译">
<Tooltip title={t('translator.tooltip.retranslate')}>
<Button
type="text"
icon={<SyncOutlined />}
......@@ -1015,18 +1011,26 @@ const Translator = () => {
</div>
</div>
</div>
{!currentImage && (
<div className="text-center text-sm text-gray-500 pb-4">
<p className="flex items-center justify-center gap-1">
<InfoCircleOutlined className="text-indigo-400" />
<span>图片翻译可自动识别图片中的文字,支持照片、截图、扫描件等</span>
</p>
</div>
)}
</div>
),
},
// {
// key: 'document',
// label: (
// <span className="flex items-center gap-2">
// <FileOutlined />
// <span>{t('translator.tabs.document')}</span>
// </span>
// ),
// children: (
// <Suspense fallback={<div className="p-8 flex justify-center"><Spin size="large" /></div>}>
// <DocumentTranslatorContent
// currentLanguage={targetLanguage}
// onLanguageChange={setTargetLanguage}
// />
// </Suspense>
// ),
// },
];
return (
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment