feat: add toast for no content

This commit is contained in:
liuyuanchuang
2026-02-05 18:22:30 +08:00
parent d562d67203
commit 2b1da79bbc
18 changed files with 832 additions and 390 deletions

View File

@@ -180,7 +180,7 @@ function App() {
markdown_content: item.markdown,
latex_content: item.latex,
mathml_content: item.mathml,
mathml_word_content: item.mathml_mw,
mml: item.mml,
rendered_image_path: item.image_blob || null,
created_at: item.created_at,
};
@@ -287,7 +287,7 @@ function App() {
markdown_content: result.markdown,
latex_content: result.latex,
mathml_content: result.mathml,
mathml_word_content: result.mathml_mw,
mml: result.mml,
rendered_image_path: result.image_blob || null,
created_at: new Date().toISOString(),
};
@@ -344,7 +344,7 @@ function App() {
markdown_content: result.markdown,
latex_content: result.latex,
mathml_content: result.mathml,
mathml_word_content: result.mathml_mw,
mml: result.mml,
rendered_image_path: result.image_blob || null,
created_at: new Date().toISOString()
};

View File

@@ -1,12 +1,12 @@
import { useState } from 'react';
import { X, Check, Copy, Download, Code2, Image as ImageIcon, FileText, Loader2, LucideIcon } 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';
import { trackExportEvent } from '../lib/analytics';
import { useLanguage } from '../contexts/LanguageContext';
import toast, { Toaster } from 'react-hot-toast';
interface ExportSidebarProps {
isOpen: boolean;
@@ -25,87 +25,6 @@ interface ExportOption {
extension?: string;
}
// Helper function to add mml: prefix to MathML
const addMMLPrefix = (mathml: string): string | null => {
try {
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(mathml, 'application/xml');
// Check for parse errors
const parseError = xmlDoc.getElementsByTagName("parsererror");
if (parseError.length > 0) {
return null;
}
// Create new document with mml namespace
const newDoc = document.implementation.createDocument(
'http://www.w3.org/1998/Math/MathML',
'mml:math',
null
);
const newMathElement = newDoc.documentElement;
// Copy display attribute if present
const displayAttr = xmlDoc.documentElement.getAttribute('display');
if (displayAttr) {
newMathElement.setAttribute('display', displayAttr);
}
// Recursive function to process nodes
const processNode = (node: Node, newParent: Element) => {
if (node.nodeType === Node.ELEMENT_NODE) {
const element = node as Element;
// Create new element with mml: prefix in the target document
const newElement = newDoc.createElementNS(
'http://www.w3.org/1998/Math/MathML',
'mml:' + element.localName
);
// Copy attributes
for (let i = 0; i < element.attributes.length; i++) {
const attr = element.attributes[i];
// Skip xmlns attributes as we handle them explicitly
if (attr.name.startsWith('xmlns')) continue;
newElement.setAttributeNS(
attr.namespaceURI,
attr.name,
attr.value
);
}
// Process children
Array.from(element.childNodes).forEach(child => {
processNode(child, newElement);
});
newParent.appendChild(newElement);
} else if (node.nodeType === Node.TEXT_NODE) {
newParent.appendChild(newDoc.createTextNode(node.nodeValue || ''));
}
};
// Process all children of the root math element
Array.from(xmlDoc.documentElement.childNodes).forEach(child => {
processNode(child, newMathElement);
});
// Serialize
const serializer = new XMLSerializer();
let prefixedMathML = serializer.serializeToString(newDoc);
// Clean up xmlns
prefixedMathML = prefixedMathML.replace(/ xmlns(:mml)?="[^"]*"/g, '');
prefixedMathML = prefixedMathML.replace(/<mml:math>/, '<mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML">');
return prefixedMathML;
} catch (err) {
console.error('Failed to process MathML:', err);
return null;
}
};
export default function ExportSidebar({ isOpen, onClose, result }: ExportSidebarProps) {
const { t } = useLanguage();
const [copiedId, setCopiedId] = useState<string | null>(null);
@@ -121,14 +40,25 @@ export default function ExportSidebar({ isOpen, onClose, result }: ExportSidebar
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 \[ \] and wrap with \( \)
const content = r.latex_content.replace(/^\\\[/, '').replace(/\\\]$/, '').trim();
// 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}\\)`;
}
},
@@ -136,7 +66,17 @@ export default function ExportSidebar({ isOpen, onClose, result }: ExportSidebar
id: 'latex_display',
label: 'LaTeX (Display)',
category: 'Code',
getContent: (r) => r.latex_content
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',
@@ -148,13 +88,7 @@ export default function ExportSidebar({ isOpen, onClose, result }: ExportSidebar
id: 'mathml_mml',
label: 'MathML (MML)',
category: 'Code',
getContent: (r) => r.mathml_content ? addMMLPrefix(r.mathml_content) : null
},
{
id: 'mathml_word',
label: 'Word MathML',
category: 'Code',
getContent: (r) => r.mathml_word_content
getContent: (r) => r.mml
},
// Image Category
{
@@ -278,22 +212,33 @@ export default function ExportSidebar({ isOpen, onClose, result }: ExportSidebar
return;
}
let content = option.getContent(result);
const 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);
}
// 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;
}
if (!content) return;
setExportingId(option.id);
try {
@@ -321,6 +266,10 @@ export default function ExportSidebar({ isOpen, onClose, result }: ExportSidebar
} catch (err) {
console.error('Action failed:', err);
toast.error(t.export.failed, {
duration: 3000,
position: 'top-center',
});
} finally {
setExportingId(null);
}
@@ -334,6 +283,41 @@ export default function ExportSidebar({ isOpen, onClose, result }: ExportSidebar
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

View File

@@ -1,6 +1,7 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import { translations, Language, TranslationKey } from '../lib/translations';
import { detectLanguageByIP } from '../lib/ipLocation';
import { updatePageMeta } from '../lib/seoHelper';
interface LanguageContextType {
language: Language;
@@ -25,6 +26,7 @@ export const LanguageProvider: React.FC<{ children: React.ReactNode }> = ({ chil
// 如果用户已经手动选择过语言则不进行IP检测
if (saved === 'en' || saved === 'zh') {
updatePageMeta(saved); // Update meta tags on initial load
return;
}
@@ -32,18 +34,21 @@ export const LanguageProvider: React.FC<{ children: React.ReactNode }> = ({ chil
detectLanguageByIP()
.then((detectedLang) => {
setLanguageState(detectedLang);
updatePageMeta(detectedLang); // Update meta tags after detection
// 注意:这里不保存到 localStorage让用户首次访问时使用IP检测的结果
// 如果用户手动切换语言,才会保存到 localStorage
})
.catch((error) => {
// IP检测失败时保持使用浏览器语言检测的结果
console.warn('Failed to detect language by IP:', error);
updatePageMeta(language); // Update with fallback language
});
}, []); // 仅在组件挂载时执行一次
const setLanguage = (lang: Language) => {
setLanguageState(lang);
localStorage.setItem('language', lang);
updatePageMeta(lang); // Update page metadata when language changes
};
const t = translations[language];

View File

@@ -63,54 +63,6 @@ function renderLatexToHtml(latex: string, fontSize: number): string {
}
}
/**
* Renders Markdown with LaTeX to HTML
* For complex Markdown, we extract and render math blocks separately
*/
function renderMarkdownToHtml(markdown: string, fontSize: number): string {
// Simple approach: extract LaTeX blocks and render them
// For full Markdown support, you'd use a Markdown parser
let html = markdown;
// Replace display math $$ ... $$
html = html.replace(/\$\$([\s\S]*?)\$\$/g, (_, latex) => {
try {
return `<div class="math-block">${katex.renderToString(latex.trim(), {
throwOnError: false,
displayMode: true,
output: 'html',
strict: false,
})}</div>`;
} catch {
return `<div class="math-block" style="color: red;">Error: ${latex}</div>`;
}
});
// Replace inline math $ ... $
html = html.replace(/\$([^$\n]+)\$/g, (_, latex) => {
try {
return katex.renderToString(latex.trim(), {
throwOnError: false,
displayMode: false,
output: 'html',
strict: false,
});
} catch {
return `<span style="color: red;">Error: ${latex}</span>`;
}
});
// Basic Markdown: newlines to <br>
html = html.replace(/\n/g, '<br>');
return `
<div style="font-size: ${fontSize}px; line-height: 1.8; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;">
${html}
</div>
`;
}
/**
* Waits for all KaTeX fonts to be loaded
*/

View File

@@ -93,16 +93,35 @@ Where:
</mfrac>
</mrow>
</math>`,
mathml_word_content: `<m:oMath xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math">
<m:f>
<m:num>
<m:r><m:t>-b ± √(b²-4ac)</m:t></m:r>
</m:num>
<m:den>
<m:r><m:t>2a</m:t></m:r>
</m:den>
</m:f>
</m:oMath>`,
mml: `<mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block">
<mml:mrow>
<mml:mi>x</mml:mi>
<mml:mo>=</mml:mo>
<mml:mfrac>
<mml:mrow>
<mml:mo>-</mml:mo>
<mml:mi>b</mml:mi>
<mml:mo>±</mml:mo>
<mml:msqrt>
<mml:mrow>
<mml:msup>
<mml:mi>b</mml:mi>
<mml:mn>2</mml:mn>
</mml:msup>
<mml:mo>-</mml:mo>
<mml:mn>4</mml:mn>
<mml:mi>a</mml:mi>
<mml:mi>c</mml:mi>
</mml:mrow>
</mml:msqrt>
</mml:mrow>
<mml:mrow>
<mml:mn>2</mml:mn>
<mml:mi>a</mml:mi>
</mml:mrow>
</mml:mfrac>
</mml:mrow>
</mml:math>`,
rendered_image_path: 'https://images.pexels.com/photos/3729557/pexels-photo-3729557.jpeg?auto=compress&cs=tinysrgb&w=800',
created_at: new Date().toISOString(),
};
@@ -161,7 +180,7 @@ Where:
markdown_content: `# Analysis for ${filename}\n\nThis is a mock analysis result generated for the uploaded file.\n\n$$ E = mc^2 $$\n\nDetected content matches widely known physics formulas.`,
latex_content: `\\documentclass{article}\n\\begin{document}\nSection{${filename}}\n\n\\[ E = mc^2 \\]\n\n\\end{document}`,
mathml_content: `<math><mi>E</mi><mo>=</mo><mi>m</mi><msup><mi>c</mi><mn>2</mn></msup></math>`,
mathml_word_content: `<m:oMath><m:r><m:t>E=mc^2</m:t></m:r></m:oMath>`,
mml: `<mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML"><mml:mi>E</mml:mi><mml:mo>=</mml:mo><mml:mi>m</mml:mi><mml:msup><mml:mi>c</mml:mi><mml:mn>2</mml:mn></mml:msup></mml:math>`,
rendered_image_path: 'https://images.pexels.com/photos/3729557/pexels-photo-3729557.jpeg?auto=compress&cs=tinysrgb&w=800', // Placeholder
created_at: new Date().toISOString(),
};

80
src/lib/seoHelper.ts Normal file
View File

@@ -0,0 +1,80 @@
import { Language } from './translations';
interface SEOContent {
title: string;
description: string;
keywords: string;
}
const seoContent: Record<Language, SEOContent> = {
zh: {
title: '⚡️ TexPixel - 公式识别工具',
description: '在线公式识别工具,支持印刷体和手写体数学公式识别,快速准确地将图片中的数学公式转换为可编辑文本。',
keywords: '公式识别,数学公式,OCR,手写公式识别,印刷体识别,AI识别,数学工具,免费,混合文字识别,texpixel,TexPixel',
},
en: {
title: '⚡️ TexPixel - Formula Recognition Tool',
description: 'Online formula recognition tool supporting printed and handwritten math formulas. Convert images to LaTeX, MathML, and Markdown quickly and accurately.',
keywords: 'formula recognition,math formula,OCR,handwriting recognition,latex,mathml,markdown,AI recognition,math tool,free,texpixel,TexPixel,document recognition',
},
};
/**
* Update document metadata based on current language
*/
export function updatePageMeta(language: Language): void {
const content = seoContent[language];
// Update title
document.title = content.title;
// Update HTML lang attribute
document.documentElement.lang = language === 'zh' ? 'zh-CN' : 'en';
// Update meta description
const metaDescription = document.querySelector('meta[name="description"]');
if (metaDescription) {
metaDescription.setAttribute('content', content.description);
}
// Update meta keywords
const metaKeywords = document.querySelector('meta[name="keywords"]');
if (metaKeywords) {
metaKeywords.setAttribute('content', content.keywords);
}
// Update Open Graph meta tags
const ogTitle = document.querySelector('meta[property="og:title"]');
if (ogTitle) {
ogTitle.setAttribute('content', content.title);
}
const ogDescription = document.querySelector('meta[property="og:description"]');
if (ogDescription) {
ogDescription.setAttribute('content', content.description);
}
// Update Twitter Card meta tags
const twitterTitle = document.querySelector('meta[name="twitter:title"]');
if (twitterTitle) {
twitterTitle.setAttribute('content', content.title);
}
const twitterDescription = document.querySelector('meta[name="twitter:description"]');
if (twitterDescription) {
twitterDescription.setAttribute('content', content.description);
}
// Update og:locale
const ogLocale = document.querySelector('meta[property="og:locale"]');
if (ogLocale) {
ogLocale.setAttribute('content', language === 'zh' ? 'zh_CN' : 'en_US');
}
}
/**
* Get SEO content for a specific language
*/
export function getSEOContent(language: Language): SEOContent {
return seoContent[language];
}

View File

@@ -61,7 +61,7 @@ export type Database = {
markdown_content: string | null;
latex_content: string | null;
mathml_content: string | null;
mathml_word_content: string | null;
mml: string | null;
rendered_image_path: string | null;
created_at: string;
};
@@ -71,7 +71,7 @@ export type Database = {
markdown_content?: string | null;
latex_content?: string | null;
mathml_content?: string | null;
mathml_word_content?: string | null;
mml?: string | null;
rendered_image_path?: string | null;
created_at?: string;
};
@@ -81,7 +81,7 @@ export type Database = {
markdown_content?: string | null;
latex_content?: string | null;
mathml_content?: string | null;
mathml_word_content?: string | null;
mml?: string | null;
rendered_image_path?: string | null;
created_at?: string;
};

View File

@@ -70,6 +70,8 @@ export const translations = {
},
failed: 'Export failed, please try again',
imageFailed: 'Failed to generate image',
noContent: 'Mixed text and formulas do not support LaTeX/MathML export. Please download DOCX format instead.',
noContentShort: 'Not supported for mixed content',
},
guide: {
next: 'Next',
@@ -164,6 +166,8 @@ export const translations = {
},
failed: '导出失败,请重试',
imageFailed: '生成图片失败',
noContent: '混合文字内容不支持 LaTeX/MathML 导出,请下载 DOCX 文件。',
noContentShort: '混合内容不支持',
},
guide: {
next: '下一步',

View File

@@ -79,7 +79,7 @@ export interface RecognitionResultData {
latex: string;
markdown: string;
mathml: string;
mathml_mw: string; // MathML for Word
mml: string; // MathML with mml: prefix
image_blob: string; // Base64 or URL? assuming string content
docx_url: string;
pdf_url: string;
@@ -96,7 +96,7 @@ export interface TaskHistoryItem {
latex: string;
markdown: string;
mathml: string;
mathml_mw: string;
mml: string;
image_blob: string;
docx_url: string;
pdf_url: string;

View File

@@ -17,7 +17,7 @@ export interface RecognitionResult {
markdown_content: string | null;
latex_content: string | null;
mathml_content: string | null;
mathml_word_content: string | null;
mml: string | null;
rendered_image_path: string | null;
created_at: string;
}
@@ -26,7 +26,6 @@ export type ExportFormat =
| 'markdown'
| 'latex'
| 'mathml'
| 'mathml-word'
| 'image'
| 'docx'
| 'pdf'