Commit 57d488f2 authored by fisherdaddy's avatar fisherdaddy

chore: update

parent c717bfff
......@@ -3,9 +3,11 @@ import '../styles/Timeline.css'; // 复用已有的Timeline样式
import events from '../data/anthropic-releases.json';
import SEO from '../components/SEO';
import { useTranslation } from '../js/i18n';
import { useScrollToTop } from '../hooks/useScrollToTop';
const AnthropicTimeline = () => {
const { t } = useTranslation();
useScrollToTop();
return (
<>
......
......@@ -3,6 +3,7 @@ import { removeBackground } from "@imgly/background-removal";
import styled from 'styled-components';
import { useTranslation } from '../js/i18n';
import SEO from './SEO';
import { useScrollToTop } from '../hooks/useScrollToTop';
// Reuse container style
const Container = styled.div`
......@@ -157,6 +158,7 @@ const PrivacyNote = styled.div`
`;
function BackgroundRemover() {
useScrollToTop();
const { t } = useTranslation();
const [selectedImage, setSelectedImage] = useState(null);
const [removedBgImage, setRemovedBgImage] = useState(null);
......
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { usePageLoading } from '../hooks/usePageLoading';
import LoadingOverlay from './LoadingOverlay';
import '../styles/HandwriteGen.css';
import html2canvas from 'html2canvas';
import {
......@@ -16,6 +18,7 @@ const { Option } = Select;
const { Title } = Typography;
function HandwritingGenerator() {
const isLoading = usePageLoading();
const [text, setText] = useState('');
const [font, setFont] = useState("'XINYE'");
const [paperType, setPaperType] = useState('Lined Paper'); // 默认值为横线纸
......@@ -30,6 +33,15 @@ function HandwritingGenerator() {
const [lineSpacing, setLineSpacing] = useState(1.25);
const [charSpacing, setCharSpacing] = useState(0);
useEffect(() => {
// Clear any loading states from previous navigation
const clearLoadingState = () => {
const event = new CustomEvent('clearLoadingState');
window.dispatchEvent(event);
};
clearLoadingState();
}, []);
const handleGenerate = () => {
const previewElement = document.querySelector('.preview-area');
html2canvas(previewElement, {
......@@ -145,8 +157,9 @@ function HandwritingGenerator() {
const backgroundOffset = -(lineSpacing * fontSize - fontSize);
return (
<div className="handwrite-container" style={{ paddingTop: '4rem' }}>
<Layout>
<>
{isLoading && <LoadingOverlay />}
<Layout className="min-h-screen pt-20">
<Sider width={300} className="site-layout-background">
<div className="settings-section">
<h2 className="title-label">手写字体生成器</h2>
......@@ -314,7 +327,7 @@ function HandwritingGenerator() {
</Content>
</Layout>
</Layout>
</div>
</>
);
}
......
// ImageBase64Converter.jsx
import React, { useState, useCallback } from 'react';
import { Title, Wrapper, Container } from '../js/SharedStyles';
import styled from 'styled-components';
import React, { useState, useCallback, useRef } from 'react';
import { useTranslation } from '../js/i18n';
import SEO from '../components/SEO';
const ConverterContainer = styled(Container)`
flex-direction: column;
gap: 24px;
padding: 24px;
background: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(10px);
border: 1px solid rgba(99, 102, 241, 0.1);
border-radius: 12px;
padding-top: 4rem; // 添加顶部内边距
`;
const Section = styled.div`
width: 100%;
import SEO from './SEO';
import styled from 'styled-components';
import { usePageLoading } from '../hooks/usePageLoading';
import LoadingOverlay from './LoadingOverlay';
// 复用相同的样式组件
const Container = styled.div`
min-height: 100vh;
background: linear-gradient(135deg, #f5f7ff 0%, #ffffff 100%);
padding: 4rem 2rem 2rem;
position: relative;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background:
linear-gradient(90deg, rgba(99, 102, 241, 0.05) 1px, transparent 1px),
linear-gradient(rgba(99, 102, 241, 0.05) 1px, transparent 1px);
background-size: 20px 20px;
pointer-events: none;
}
`;
const Label = styled.label`
font-weight: 500;
font-size: 14px;
color: #374151;
margin-bottom: 12px;
display: block;
letter-spacing: 0.1px;
const ContentWrapper = styled.div`
max-width: 1400px;
margin: 0 auto;
position: relative;
z-index: 1;
`;
const StyledInputText = styled.textarea`
width: 100%;
height: 120px;
font-size: 15px;
padding: 16px;
border: 1px solid rgba(99, 102, 241, 0.1);
border-radius: 12px;
background: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(10px);
box-sizing: border-box;
outline: none;
resize: none;
transition: all 0.3s ease;
line-height: 1.5;
&:focus {
border-color: rgba(99, 102, 241, 0.3);
box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.1);
}
const Title = styled.h2`
font-size: 1.8rem;
margin-bottom: 1.5rem;
background: linear-gradient(135deg, #6366F1 0%, #4F46E5 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
font-weight: 700;
letter-spacing: -0.02em;
text-align: center;
`;
const FileInputWrapper = styled.div`
const FileInputWrapper = styled.label`
position: relative;
width: 100%;
height: 120px;
height: 200px;
border: 2px dashed rgba(99, 102, 241, 0.2);
border-radius: 12px;
display: flex;
......@@ -62,6 +57,8 @@ const FileInputWrapper = styled.div`
justify-content: center;
cursor: pointer;
transition: all 0.3s ease;
background: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(10px);
&:hover {
border-color: rgba(99, 102, 241, 0.4);
......@@ -78,255 +75,282 @@ const FileInputWrapper = styled.div`
span {
color: #6366F1;
font-size: 14px;
font-size: 1rem;
display: flex;
align-items: center;
gap: 8px;
pointer-events: none;
}
`;
const ResultContainer = styled.div`
position: relative;
const ResultArea = styled.div`
width: 100%;
min-height: 200px;
padding: 1rem;
background: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(10px);
border-radius: 12px;
border: 1px solid rgba(99, 102, 241, 0.1);
padding: 16px;
min-height: 120px;
border-radius: 12px;
position: relative;
textarea {
width: 100%;
min-height: 180px;
border: none;
background: transparent;
resize: vertical;
outline: none;
font-family: monospace;
font-size: 0.875rem;
line-height: 1.5;
}
`;
const ActionButton = styled.button`
background: rgba(99, 102, 241, 0.1);
border: none;
border-radius: 6px;
display: inline-flex;
align-items: center;
gap: 1.5px;
padding: 6px 12px;
font-size: 0.875rem;
font-weight: 500;
border-radius: 0.5rem;
transition: all 0.2s;
border: none;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: #6366F1;
transition: all 0.3s ease;
&:hover {
background: rgba(99, 102, 241, 0.2);
}
&.active {
background: #6366F1;
color: white;
}
${props => props.variant === 'success' && `
background-color: #DEF7EC;
color: #03543F;
`}
${props => !props.variant && `
background-color: rgba(255, 255, 255, 0.5);
color: #4B5563;
&:hover {
background-color: #EEF2FF;
color: #4F46E5;
}
`}
svg {
width: 14px;
height: 14px;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
`;
const ImagePreviewContainer = styled.div`
position: relative;
width: 100%;
margin-top: 8px;
display: flex;
flex-direction: column;
gap: 12px;
`;
const ThumbnailContainer = styled.div`
position: relative;
width: 120px;
height: 120px;
const PreviewImage = styled.img`
max-width: 100%;
max-height: 300px;
margin: 1rem 0;
border-radius: 8px;
overflow: hidden;
border: 1px solid rgba(99, 102, 241, 0.1);
`;
const PreviewActions = styled.div`
display: flex;
gap: 8px;
margin-top: 8px;
`;
const Thumbnail = styled.img`
width: 100%;
height: 100%;
object-fit: cover;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
`;
const ImageDetails = styled.div`
font-size: 13px;
font-size: 0.875rem;
color: #6B7280;
margin-top: 4px;
`;
const ErrorText = styled.div`
color: #EF4444;
font-size: 14px;
margin-top: 8px;
margin-top: 0.5rem;
`;
function ImageBase64Converter() {
const { t } = useTranslation();
// 图片转 Base64 的状态
const [imageFile, setImageFile] = useState(null);
const isLoading = usePageLoading();
const [base64String, setBase64String] = useState('');
const [previewUrl, setPreviewUrl] = useState('');
const [isCopied, setIsCopied] = useState(false);
// Base64 转图片的状态
const [inputBase64, setInputBase64] = useState('');
const [imageSrc, setImageSrc] = useState('');
const [imageFile, setImageFile] = useState(null);
const [error, setError] = useState('');
const fileInputRef = useRef(null);
// 清除所有状态
const clearStates = () => {
setBase64String('');
setPreviewUrl('');
setImageFile(null);
setError('');
};
// 处理图片上传
const handleImageUpload = (e) => {
const file = e.target.files[0];
// 处理图片转 Base64
const handleFileChange = (event) => {
const file = event.target.files[0];
if (file) {
setImageFile(file);
const reader = new FileReader();
reader.onloadend = () => {
setBase64String(reader.result);
reader.onload = (e) => {
const base64 = e.target.result;
setBase64String(base64);
setPreviewUrl(base64);
setError('');
};
reader.onerror = () => {
setError(t('tools.imageBase64Converter.readError'));
};
reader.readAsDataURL(file);
}
// 重置 input 的 value,这样同一个文件也能触发 change 事件
event.target.value = '';
};
// 复制 Base64 字符串
const handleCopy = useCallback(() => {
navigator.clipboard.writeText(base64String).then(() => {
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000);
});
}, [base64String]);
// 处理 Base64 转图片
const handleBase64Input = (event) => {
const input = event.target.value;
setBase64String(input);
setError('');
if (!input) {
setPreviewUrl('');
setImageFile(null);
return;
}
// 处理 Base64 输入变化
const handleBase64InputChange = (e) => {
const input = e.target.value.trim();
setInputBase64(input);
if (input) {
let src = input;
if (!input.startsWith('data:image/')) {
// 尝试自动推断图片类型
const match = input.match(/^data:(image\/[a-zA-Z]+);base64,/);
let mimeType = 'image/png'; // 默认类型
if (match && match[1]) {
mimeType = match[1];
try {
// 尝试验证和修复 base64 字符串
let validBase64 = input.trim();
// 如果不是以 data:image 开头,尝试添加
if (!validBase64.startsWith('data:image')) {
// 检查是否只包含 base64 字符
const base64Regex = /^[A-Za-z0-9+/=]+$/;
if (base64Regex.test(validBase64)) {
validBase64 = `data:image/png;base64,${validBase64}`;
}
src = `data:${mimeType};base64,${input}`;
}
setImageSrc(src);
setError('');
} else {
setImageSrc('');
setError('');
}
};
// 图片加载错误处理
const handleImageError = () => {
setError(t('tools.imageBase64Converter.invalidBase64'));
setImageSrc('');
// 创建一个新的图片对象来验证 base64 字符串
const img = new Image();
img.onload = () => {
setPreviewUrl(validBase64);
setError('');
};
img.onerror = () => {
setPreviewUrl('');
setError(t('tools.imageBase64Converter.invalidBase64'));
};
img.src = validBase64;
} catch (err) {
setPreviewUrl('');
setError(t('tools.imageBase64Converter.invalidBase64'));
}
};
// 下载图片
const handleDownload = () => {
if (!previewUrl) return;
const link = document.createElement('a');
link.href = imageSrc;
// 尝试从 Base64 字符串中提取文件类型和扩展名
let fileName = 'downloaded_image';
const match = imageSrc.match(/^data:(image\/[a-zA-Z]+);base64,/);
if (match && match[1]) {
const mime = match[1];
const extension = mime.split('/')[1];
fileName += `.${extension}`;
} else {
fileName += `.png`;
}
link.download = fileName;
link.href = previewUrl;
link.download = imageFile ? imageFile.name : 'image.png';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
const handleCopy = useCallback(() => {
navigator.clipboard.writeText(base64String).then(() => {
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000);
});
}, [base64String]);
return (
<>
{isLoading && <LoadingOverlay />}
<SEO
title={t('tools.imageBase64Converter.title')}
description={t('tools.imageBase64Converter.description')}
/>
<Wrapper>
<Title>{t('tools.imageBase64Converter.title')}</Title>
<ConverterContainer>
<Section>
<Label>{t('tools.imageBase64Converter.imageToBase64')}</Label>
<FileInputWrapper>
<input
type="file"
accept="image/*"
onChange={handleImageUpload}
/>
<span>
<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor">
<path d="M19 7v2.99s-1.99.01-2 0V7h-3s.01-1.99 0-2h3V2h2v3h3v2h-3zm-3 4V8h-3V5H5c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2v-8h-3zM5 19l3-4 2 3 3-4 4 5H5z"/>
</svg>
{t('tools.imageBase64Converter.dragOrClick')}
</span>
</FileInputWrapper>
{base64String && (
<ResultContainer>
<pre>{base64String}</pre>
<ActionButton onClick={handleCopy} className={isCopied ? 'active' : ''}>
<Container>
<ContentWrapper>
<Title>{t('tools.imageBase64Converter.title')}</Title>
<div className="flex flex-col gap-6">
<div className="space-y-2">
<div className="block text-sm font-medium text-gray-700">
{t('tools.imageBase64Converter.imageToBase64')}
</div>
<FileInputWrapper>
<input
type="file"
accept="image/*"
onChange={handleFileChange}
/>
<span>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M4 3a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V5a2 2 0 00-2-2H4zm12 12H4l4-8 3 6 2-4 3 6z" clipRule="evenodd" />
</svg>
{t('tools.imageBase64Converter.dragOrClick')}
</span>
</FileInputWrapper>
</div>
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700">
{t('tools.imageBase64Converter.base64ToImage')}
</label>
<ResultArea>
<textarea
value={base64String}
onChange={handleBase64Input}
placeholder={t('tools.imageBase64Converter.base64InputPlaceholder')}
/>
</ResultArea>
<div className="flex justify-end gap-2">
{error && (
<div className="text-red-500 text-sm flex-1 pt-2">
{error}
</div>
)}
<ActionButton
onClick={handleCopy}
disabled={!base64String}
variant={isCopied ? 'success' : undefined}
>
{isCopied ? (
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
</svg>
<>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
</svg>
{t('copied')}
</>
) : (
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/>
</svg>
<>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/>
</svg>
{t('copy')}
</>
)}
{isCopied ? t('tools.jsonFormatter.copiedMessage') : t('tools.jsonFormatter.copyButton')}
</ActionButton>
<ThumbnailContainer>
<Thumbnail src={base64String} alt="Preview" />
</ThumbnailContainer>
</div>
</div>
{previewUrl && (
<div className="space-y-2">
<div className="flex justify-between items-center">
<label className="block text-sm font-medium text-gray-700">
{t('tools.imageBase64Converter.preview')}
</label>
<ActionButton
onClick={handleDownload}
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/>
</svg>
{t('tools.imageBase64Converter.download')}
</ActionButton>
</div>
<PreviewImage src={previewUrl} alt="Preview" />
{imageFile && (
<ImageDetails>
{t('tools.imageBase64Converter.fileName')}: {imageFile.name}<br />
{t('tools.imageBase64Converter.fileSize')}: {(imageFile.size / 1024).toFixed(2)} KB
</ImageDetails>
)}
</ResultContainer>
)}
</Section>
<Section>
<Label>{t('tools.imageBase64Converter.base64ToImage')}</Label>
<StyledInputText
value={inputBase64}
onChange={handleBase64InputChange}
placeholder={t('tools.imageBase64Converter.base64InputPlaceholder')}
/>
{error && <ErrorText>{error}</ErrorText>}
{imageSrc && (
<ImagePreviewContainer>
<ThumbnailContainer>
<Thumbnail src={imageSrc} alt="Thumbnail" onError={handleImageError} />
</ThumbnailContainer>
<PreviewActions>
<ActionButton onClick={handleDownload}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2M12 12v9m0 0l-3-3m3 3l3-3"/>
</svg>
{t('tools.imageBase64Converter.download')}
</ActionButton>
</PreviewActions>
</ImagePreviewContainer>
</div>
)}
</Section>
</ConverterContainer>
</Wrapper>
</div>
</ContentWrapper>
</Container>
</>
);
}
......
......@@ -3,6 +3,8 @@ import styled from 'styled-components';
import { useTranslation } from '../js/i18n';
import SEO from './SEO';
import imageCompression from 'browser-image-compression';
import { usePageLoading } from '../hooks/usePageLoading';
import LoadingOverlay from './LoadingOverlay';
// 复用 MarkdownToImage 的容器样式
const Container = styled.div`
......@@ -402,6 +404,7 @@ const truncateFilename = (filename, maxLength = 10) => {
function ImageCompressor() {
const { t } = useTranslation();
const isLoading = usePageLoading();
const [images, setImages] = useState([]); // 修改为数组存储多张图片
const [compressedImages, setCompressedImages] = useState([]);
const [settings, setSettings] = useState({
......@@ -523,6 +526,7 @@ function ImageCompressor() {
return (
<>
{isLoading && <LoadingOverlay />}
<SEO
title={t('tools.imageCompressor.title')}
description={t('tools.imageCompressor.description')}
......
......@@ -2,6 +2,8 @@ import React, { useState, useRef, useCallback, useEffect } from 'react';
import styled from 'styled-components';
import { useTranslation } from '../js/i18n';
import SEO from './SEO';
import { usePageLoading } from '../hooks/usePageLoading';
import LoadingOverlay from './LoadingOverlay';
// 复用 MarkdownToImage 的基础容器样式
const Container = styled.div`
......@@ -164,6 +166,7 @@ const PrivacyNote = styled.div`
function ImageWatermark() {
const { t } = useTranslation();
const isLoading = usePageLoading();
const [image, setImage] = useState(null);
const [watermarkText, setWatermarkText] = useState('');
const [watermarkImage, setWatermarkImage] = useState(null);
......@@ -339,6 +342,7 @@ function ImageWatermark() {
return (
<>
{isLoading && <LoadingOverlay />}
<SEO
title={t('tools.imageWatermark.title')}
description={t('tools.imageWatermark.description')}
......
......@@ -2,6 +2,8 @@ import React, { useState, useEffect } from 'react';
import { useTranslation } from '../js/i18n';
import SEO from '../components/SEO';
import styled from 'styled-components';
import { usePageLoading } from '../hooks/usePageLoading';
import LoadingOverlay from './LoadingOverlay';
const Container = styled.div`
min-height: 100vh;
......@@ -48,6 +50,7 @@ function JsonFormatter() {
const [parsedJson, setParsedJson] = useState(null);
const [isCopied, setIsCopied] = useState(false);
const [isCompressed, setIsCompressed] = useState(false);
const isLoading = usePageLoading();
useEffect(() => {
try {
......@@ -80,6 +83,7 @@ function JsonFormatter() {
return (
<>
{isLoading && <LoadingOverlay />}
<SEO
title={t('tools.jsonFormatter.title')}
description={t('tools.jsonFormatter.description')}
......
......@@ -6,6 +6,8 @@ import DOMPurify from 'dompurify';
import SEO from './SEO';
import { useTranslation } from '../js/i18n';
import html2canvas from 'html2canvas';
import { usePageLoading } from '../hooks/usePageLoading';
import LoadingOverlay from './LoadingOverlay';
// 容器样式
const Container = styled.div`
......@@ -144,6 +146,7 @@ const DownloadButton = styled.button`
function HtmlPreview() {
const [html, setHtml] = useState('');
const { t } = useTranslation();
const isLoading = usePageLoading();
// 处理 LaTeX 公式
const processLatex = (content) => {
......@@ -269,6 +272,7 @@ function HtmlPreview() {
return (
<>
{isLoading && <LoadingOverlay />}
<SEO
title={t('tools.latex2image.title')}
description={t('tools.latex2image.description')}
......
import React from 'react';
const LoadingOverlay = () => {
return (
<div className="fixed inset-0 bg-white/80 flex items-center justify-center z-50">
<div className="flex flex-col items-center gap-4">
<div className="w-12 h-12 border-4 border-indigo-500 border-t-transparent rounded-full animate-spin"></div>
<div className="text-indigo-600 font-medium">Loading...</div>
</div>
</div>
);
};
export default LoadingOverlay;
import React, { useState, useRef } from 'react';
import React, { useState, useRef, useEffect } from 'react';
import styled from 'styled-components';
import { marked } from 'marked';
import { useTranslation } from '../js/i18n';
import SEO from './SEO';
import DOMPurify from 'dompurify';
import { usePageLoading } from '../hooks/usePageLoading';
import LoadingOverlay from './LoadingOverlay';
// 更新预设模板
const templates = [
......@@ -303,11 +305,12 @@ const Preview = styled.div`
hyphens: auto;
`;
function TextToImage() {
function MarkdownToImage() {
const { t } = useTranslation();
const [text, setText] = useState('');
const [selectedTemplate, setSelectedTemplate] = useState(templates[0]);
const previewRef = useRef(null);
const isLoading = usePageLoading();
const formatText = (text) => {
return marked.parse(text, {
......@@ -419,6 +422,7 @@ function TextToImage() {
return (
<>
{isLoading && <LoadingOverlay />}
<SEO
title={t('tools.markdown2image.title')}
description={t('tools.markdown2image.description')}
......@@ -473,4 +477,4 @@ function TextToImage() {
);
}
export default TextToImage;
\ No newline at end of file
export default MarkdownToImage;
\ No newline at end of file
import React from 'react';
import { useScrollToTop } from '../hooks/useScrollToTop';
import '../styles/Timeline.css';
import events from '../data/openai-releases.json';
import SEO from '../components/SEO';
import { useTranslation } from '../js/i18n';
const Timeline = () => {
useScrollToTop();
const { t } = useTranslation();
return (
......
// PricingChart.jsx
import React, { useState } from 'react';
import { useScrollToTop } from '../hooks/useScrollToTop';
import '../styles/PricingChart.css';
const ChartLegend = ({ onLegendClick, highlightedBarTypes }) => {
......@@ -99,6 +100,7 @@ const GridLines = () => (
);
const PricingChart = ({ data }) => {
useScrollToTop();
const [highlightedBarTypes, setHighlightedBarTypes] = useState({
input: true,
output: true,
......
import React from 'react';
import { useScrollToTop } from '../hooks/useScrollToTop';
import PricingChart from '../components/PricingChart';
import OpenaiPricing from '../data/openai-pricing.json';
import LLMPricing from '../data/llm-pricing.json';
......@@ -6,6 +7,7 @@ import VisionPricing from '../data/vision-model-pricing.json';
import SEO from '../components/SEO';
const PricingCharts = () => {
useScrollToTop();
const lastUpdateTime = '2024-11-06 21:30';
return (
......
......@@ -2,6 +2,7 @@ import React, { useState, useRef } from 'react';
import styled from 'styled-components';
import html2canvas from 'html2canvas';
import { useTranslation } from '../js/i18n';
import { useScrollToTop } from '../hooks/useScrollToTop';
// 更新中文字体数组,包含显示名称和 CSS 字体族名称
const chineseFonts = [
......@@ -203,6 +204,7 @@ const DownloadButton = styled.button`
`;
function QuoteCard() {
useScrollToTop();
const { t } = useTranslation();
const [chineseText, setChineseText] = useState('');
......
import React, { useState, useRef, useEffect } from 'react';
import styled from 'styled-components';
import { useTranslation } from '../js/i18n';
import { usePageLoading } from '../hooks/usePageLoading';
import LoadingOverlay from './LoadingOverlay';
import SEO from './SEO';
const SubtitleMaker = () => {
const { t } = useTranslation();
const [imageSrc, setImageSrc] = useState(null);
const [subtitles, setSubtitles] = useState([{ text: '' }]);
const canvasRef = useRef(null);
const [finalImage, setFinalImage] = useState(null);
const [globalSettings, setGlobalSettings] = useState({
fontSize: 48,
lineHeight: 100,
strokeWidth: 2,
textColor: '#FFE135',
strokeColor: '#000000'
});
const fileInputRef = useRef(null);
// 预设的字幕颜色选项 - 只保留最常用的几个
const presetColors = [
{ name: t('tools.subtitleGenerator.presetColors.classicYellow'), value: '#FFE135' },
{ name: t('tools.subtitleGenerator.presetColors.pureWhite'), value: '#FFFFFF' },
{ name: t('tools.subtitleGenerator.presetColors.vividOrange'), value: '#FFA500' },
{ name: t('tools.subtitleGenerator.presetColors.neonGreen'), value: '#ADFF2F' },
{ name: t('tools.subtitleGenerator.presetColors.lightBlue'), value: '#00FFFF' },
{ name: t('tools.subtitleGenerator.presetColors.brightPink'), value: '#FF69B4' },
];
// 处理图片上传
const handleImageUpload = (e) => {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = () => {
setImageSrc(reader.result);
}
reader.readAsDataURL(file);
}
}
// 更新字幕内容
const updateSubtitle = (index, text) => {
const newSubtitles = [...subtitles];
newSubtitles[index] = { text };
setSubtitles(newSubtitles);
};
// 删除字幕行
const removeSubtitleLine = (index) => {
setSubtitles(subtitles.filter((_, i) => i !== index));
};
// 增加字幕行
const addSubtitleLine = () => {
setSubtitles([...subtitles, { text: '' }]);
};
// 绘制字幕到canvas
useEffect(() => {
if (imageSrc) {
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
const image = new Image();
image.src = imageSrc;
image.onload = () => {
// 计算总高度
const totalHeight = image.height +
subtitles.slice(1).length * (globalSettings.lineHeight + 1);
canvas.width = image.width;
canvas.height = totalHeight;
// 绘制图片
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(image, 0, 0);
// 获取第一行字幕区域的背景图像数据
const subtitleBackgroundData = ctx.getImageData(
0,
image.height - globalSettings.lineHeight,
canvas.width,
globalSettings.lineHeight
);
// 设置字体和对齐方式
ctx.textAlign = 'center';
ctx.font = `${globalSettings.fontSize}px Arial`;
ctx.lineWidth = globalSettings.strokeWidth;
// 绘制第一行字幕
let yPosition = image.height - globalSettings.lineHeight;
ctx.strokeStyle = globalSettings.strokeColor;
ctx.fillStyle = globalSettings.textColor;
const firstTextY = yPosition + globalSettings.lineHeight / 2 + globalSettings.fontSize / 3;
ctx.strokeText(subtitles[0].text, canvas.width / 2, firstTextY);
ctx.fillText(subtitles[0].text, canvas.width / 2, firstTextY);
// 设置 yPosition 为图片底部,准备绘制后续字幕
yPosition = image.height;
// 绘制后续字幕行
subtitles.slice(1).forEach((subtitle) => {
// 绘制分隔线
ctx.fillStyle = '#e5e7eb';
ctx.fillRect(0, yPosition, canvas.width, 1);
yPosition += 0;
// 绘制背景(使用第一行字幕的背景)
ctx.putImageData(subtitleBackgroundData, 0, yPosition);
// 绘制字幕文字
ctx.strokeStyle = globalSettings.strokeColor;
ctx.fillStyle = globalSettings.textColor;
const textY = yPosition + globalSettings.lineHeight / 2 + globalSettings.fontSize / 3;
ctx.strokeText(subtitle.text, canvas.width / 2, textY);
ctx.fillText(subtitle.text, canvas.width / 2, textY);
yPosition += globalSettings.lineHeight;
});
setFinalImage(canvas.toDataURL('image/png'));
};
}
}, [imageSrc, subtitles, globalSettings]);
// 下载最终的图片
const downloadImage = () => {
if (finalImage) {
const link = document.createElement('a');
link.href = finalImage;
link.download = 'subtitle-image.png';
link.click();
}
}
return (
<Container>
<SettingsPanel>
<h3>{t('tools.subtitleGenerator.uploadImage')}</h3>
<UploadButton onClick={() => fileInputRef.current.click()}>
<HiddenFileInput
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleImageUpload}
/>
{t('tools.subtitleGenerator.dropOrClick')}
</UploadButton>
{imageSrc && (
<>
<h3>{t('tools.subtitleGenerator.globalSettings')}</h3>
<SettingGroup>
<label>{t('tools.subtitleGenerator.fontColor')}</label>
<ColorPickerContainer>
<ColorInput
type="color"
value={globalSettings.textColor}
onChange={(e) => setGlobalSettings({
...globalSettings,
textColor: e.target.value
})}
/>
<ColorPresets>
{presetColors.map((color) => (
<ColorPresetButton
key={color.value}
$color={color.value}
onClick={() => setGlobalSettings({
...globalSettings,
textColor: color.value
})}
title={color.name}
/>
))}
</ColorPresets>
</ColorPickerContainer>
</SettingGroup>
<SettingGroup>
<label>{t('tools.subtitleGenerator.fontSize')}: {globalSettings.fontSize}px</label>
<RangeInput
type="range"
value={globalSettings.fontSize}
onChange={(e) => setGlobalSettings({
...globalSettings,
fontSize: parseInt(e.target.value)
})}
min="48"
max="120"
/>
</SettingGroup>
<SettingGroup>
<label>{t('tools.subtitleGenerator.subtitleHeight')}: {globalSettings.lineHeight}px</label>
<RangeInput
type="range"
value={globalSettings.lineHeight}
onChange={(e) => setGlobalSettings({
...globalSettings,
lineHeight: parseInt(e.target.value)
})}
min="60"
max="160"
/>
</SettingGroup>
<h3>{t('tools.subtitleGenerator.subtitleSettings')}</h3>
{subtitles.map((subtitle, index) => (
<SubtitleInput key={index}>
<input
type="text"
value={subtitle.text}
onChange={(e) => updateSubtitle(index, e.target.value)}
placeholder={`字幕 ${index + 1}`}
/>
{index > 0 && (
<DeleteButton onClick={() => removeSubtitleLine(index)}>
删除
</DeleteButton>
)}
</SubtitleInput>
))}
<Button onClick={addSubtitleLine}>{t('tools.subtitleGenerator.addSubtitleLine')}</Button>
</>
)}
</SettingsPanel>
<PreviewPanel>
<h3>{t('tools.subtitleGenerator.preview')}</h3>
{finalImage && (
<>
<PreviewImage src={finalImage} alt="Preview" />
<Button onClick={downloadImage}>{t('tools.subtitleGenerator.downloadImage')}</Button>
</>
)}
<canvas ref={canvasRef} style={{ display: 'none' }} />
</PreviewPanel>
</Container>
);
};
// Styled Components
const Container = styled.div`
display: flex;
gap: 2rem;
padding: 4rem 2rem 2rem;
background: linear-gradient(135deg, #f5f7ff 0%, #ffffff 100%);
min-height: 100vh;
background: linear-gradient(135deg, #f5f7ff 0%, #ffffff 100%);
padding: 4rem 2rem 2rem;
position: relative;
&::before {
......@@ -257,12 +16,37 @@ const Container = styled.div`
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 4rem;
background: linear-gradient(135deg, #f5f7ff 0%, #ffffff 100%);
z-index: -1;
right: 0;
bottom: 0;
background:
linear-gradient(90deg, rgba(99, 102, 241, 0.05) 1px, transparent 1px),
linear-gradient(rgba(99, 102, 241, 0.05) 1px, transparent 1px);
background-size: 20px 20px;
pointer-events: none;
}
`;
const ContentWrapper = styled.div`
max-width: 1400px;
margin: 0 auto;
position: relative;
z-index: 1;
`;
const Title = styled.h2`
font-size: 1.8rem;
margin-bottom: 1.5rem;
background: linear-gradient(135deg, #6366F1 0%, #4F46E5 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
font-weight: 700;
letter-spacing: -0.02em;
text-align: center;
`;
const MainContent = styled.div`
display: flex;
gap: 2rem;
@media (max-width: 768px) {
flex-direction: column;
}
......@@ -476,4 +260,270 @@ const ColorPresetButton = styled.button`
}
`;
const ControlPanel = styled.div`
flex: 1;
background: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(10px);
border-radius: 16px;
padding: 1.5rem;
box-shadow: 0 8px 32px rgba(99, 102, 241, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
`;
const SubtitleMaker = () => {
const { t } = useTranslation();
const isLoading = usePageLoading();
const [imageSrc, setImageSrc] = useState(null);
const [subtitles, setSubtitles] = useState([{ text: '' }]);
const canvasRef = useRef(null);
const [finalImage, setFinalImage] = useState(null);
const [globalSettings, setGlobalSettings] = useState({
fontSize: 48,
lineHeight: 100,
strokeWidth: 2,
textColor: '#FFE135',
strokeColor: '#000000'
});
const fileInputRef = useRef(null);
// 预设的字幕颜色选项 - 只保留最常用的几个
const presetColors = [
{ name: t('tools.subtitleGenerator.presetColors.classicYellow'), value: '#FFE135' },
{ name: t('tools.subtitleGenerator.presetColors.pureWhite'), value: '#FFFFFF' },
{ name: t('tools.subtitleGenerator.presetColors.vividOrange'), value: '#FFA500' },
{ name: t('tools.subtitleGenerator.presetColors.neonGreen'), value: '#ADFF2F' },
{ name: t('tools.subtitleGenerator.presetColors.lightBlue'), value: '#00FFFF' },
{ name: t('tools.subtitleGenerator.presetColors.brightPink'), value: '#FF69B4' },
];
// 处理图片上传
const handleImageUpload = (e) => {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = () => {
setImageSrc(reader.result);
}
reader.readAsDataURL(file);
}
}
// 更新字幕内容
const updateSubtitle = (index, text) => {
const newSubtitles = [...subtitles];
newSubtitles[index] = { text };
setSubtitles(newSubtitles);
};
// 删除字幕行
const removeSubtitleLine = (index) => {
setSubtitles(subtitles.filter((_, i) => i !== index));
};
// 增加字幕行
const addSubtitleLine = () => {
setSubtitles([...subtitles, { text: '' }]);
};
// 绘制字幕到canvas
useEffect(() => {
if (imageSrc) {
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
const image = new Image();
image.src = imageSrc;
image.onload = () => {
// 计算总高度
const totalHeight = image.height +
subtitles.slice(1).length * (globalSettings.lineHeight + 1);
canvas.width = image.width;
canvas.height = totalHeight;
// 绘制图片
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(image, 0, 0);
// 获取第一行字幕区域的背景图像数据
const subtitleBackgroundData = ctx.getImageData(
0,
image.height - globalSettings.lineHeight,
canvas.width,
globalSettings.lineHeight
);
// 设置字体和对齐方式
ctx.textAlign = 'center';
ctx.font = `${globalSettings.fontSize}px Arial`;
ctx.lineWidth = globalSettings.strokeWidth;
// 绘制第一行字幕
let yPosition = image.height - globalSettings.lineHeight;
ctx.strokeStyle = globalSettings.strokeColor;
ctx.fillStyle = globalSettings.textColor;
const firstTextY = yPosition + globalSettings.lineHeight / 2 + globalSettings.fontSize / 3;
ctx.strokeText(subtitles[0].text, canvas.width / 2, firstTextY);
ctx.fillText(subtitles[0].text, canvas.width / 2, firstTextY);
// 设置 yPosition 为图片底部,准备绘制后续字幕
yPosition = image.height;
// 绘制后续字幕行
subtitles.slice(1).forEach((subtitle) => {
// 绘制分隔线
ctx.fillStyle = '#e5e7eb';
ctx.fillRect(0, yPosition, canvas.width, 1);
yPosition += 0;
// 绘制背景(使用第一行字幕的背景)
ctx.putImageData(subtitleBackgroundData, 0, yPosition);
// 绘制字幕文字
ctx.strokeStyle = globalSettings.strokeColor;
ctx.fillStyle = globalSettings.textColor;
const textY = yPosition + globalSettings.lineHeight / 2 + globalSettings.fontSize / 3;
ctx.strokeText(subtitle.text, canvas.width / 2, textY);
ctx.fillText(subtitle.text, canvas.width / 2, textY);
yPosition += globalSettings.lineHeight;
});
setFinalImage(canvas.toDataURL('image/png'));
};
}
}, [imageSrc, subtitles, globalSettings]);
// 下载最终的图片
const downloadImage = () => {
if (finalImage) {
const link = document.createElement('a');
link.href = finalImage;
link.download = 'subtitle-image.png';
link.click();
}
}
return (
<>
{isLoading && <LoadingOverlay />}
<SEO
title={t('tools.subtitleGenerator.title')}
description={t('tools.subtitleGenerator.description')}
/>
<Container>
<ContentWrapper>
<Title>{t('tools.subtitleGenerator.title')}</Title>
<MainContent>
{/* Left Panel */}
<ControlPanel>
<h3>{t('tools.subtitleGenerator.uploadImage')}</h3>
<UploadButton onClick={() => fileInputRef.current.click()}>
<HiddenFileInput
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleImageUpload}
/>
{t('tools.subtitleGenerator.dropOrClick')}
</UploadButton>
{imageSrc && (
<>
<h3>{t('tools.subtitleGenerator.globalSettings')}</h3>
<SettingGroup>
<label>{t('tools.subtitleGenerator.fontColor')}</label>
<ColorPickerContainer>
<ColorInput
type="color"
value={globalSettings.textColor}
onChange={(e) => setGlobalSettings({
...globalSettings,
textColor: e.target.value
})}
/>
<ColorPresets>
{presetColors.map((color) => (
<ColorPresetButton
key={color.value}
$color={color.value}
onClick={() => setGlobalSettings({
...globalSettings,
textColor: color.value
})}
title={color.name}
/>
))}
</ColorPresets>
</ColorPickerContainer>
</SettingGroup>
<SettingGroup>
<label>{t('tools.subtitleGenerator.fontSize')}: {globalSettings.fontSize}px</label>
<RangeInput
type="range"
value={globalSettings.fontSize}
onChange={(e) => setGlobalSettings({
...globalSettings,
fontSize: parseInt(e.target.value)
})}
min="48"
max="120"
/>
</SettingGroup>
<SettingGroup>
<label>{t('tools.subtitleGenerator.subtitleHeight')}: {globalSettings.lineHeight}px</label>
<RangeInput
type="range"
value={globalSettings.lineHeight}
onChange={(e) => setGlobalSettings({
...globalSettings,
lineHeight: parseInt(e.target.value)
})}
min="60"
max="160"
/>
</SettingGroup>
<h3>{t('tools.subtitleGenerator.subtitleSettings')}</h3>
{subtitles.map((subtitle, index) => (
<SubtitleInput key={index}>
<input
type="text"
value={subtitle.text}
onChange={(e) => updateSubtitle(index, e.target.value)}
placeholder={`字幕 ${index + 1}`}
/>
{index > 0 && (
<DeleteButton onClick={() => removeSubtitleLine(index)}>
删除
</DeleteButton>
)}
</SubtitleInput>
))}
<Button onClick={addSubtitleLine}>{t('tools.subtitleGenerator.addSubtitleLine')}</Button>
</>
)}
</ControlPanel>
{/* Right Panel */}
<PreviewPanel>
<h3>{t('tools.subtitleGenerator.preview')}</h3>
{finalImage && (
<>
<PreviewImage src={finalImage} alt="Preview" />
<Button onClick={downloadImage}>{t('tools.subtitleGenerator.downloadImage')}</Button>
</>
)}
<canvas ref={canvasRef} style={{ display: 'none' }} />
</PreviewPanel>
</MainContent>
</ContentWrapper>
</Container>
</>
);
};
export default SubtitleMaker;
......@@ -3,6 +3,8 @@ import { removeBackground } from "@imgly/background-removal";
import styled from 'styled-components';
import { useTranslation } from '../js/i18n';
import '../styles/fonts.css';
import { usePageLoading } from '../hooks/usePageLoading';
import LoadingOverlay from './LoadingOverlay';
// 复用现有的基础容器样式
const Container = styled.div`
......@@ -255,6 +257,7 @@ const PrivacyNote = styled.div`
function TextBehindImage() {
const { t } = useTranslation();
const isLoading = usePageLoading();
const [selectedImage, setSelectedImage] = useState(null);
const [isImageSetupDone, setIsImageSetupDone] = useState(false);
const [removedBgImageUrl, setRemovedBgImageUrl] = useState(null);
......@@ -460,256 +463,259 @@ function TextBehindImage() {
}, []);
return (
<Container>
<ContentWrapper>
<ControlPanel>
<Title>{t('tools.textBehindImage.title')}</Title>
<SettingsGroup>
<GroupTitle>{t('tools.textBehindImage.imageUpload')}</GroupTitle>
<ImageUploadArea onClick={() => fileInputRef.current.click()}>
<input
type="file"
ref={fileInputRef}
style={{ display: 'none' }}
accept="image/*"
onChange={handleImageUpload}
/>
{t('tools.textBehindImage.uploadPrompt')}
</ImageUploadArea>
{/* 添加隐私提示 */}
<PrivacyNote>
{t('tools.textBehindImage.privacyNote')}
</PrivacyNote>
</SettingsGroup>
{textSets.map(textSet => (
<SettingsGroup key={textSet.id}>
<GroupTitle>{t('tools.textBehindImage.textSettings')}</GroupTitle>
<InputWrapper>
<Label>
{t('tools.textBehindImage.text')}
<span>{textSet.text.length} {t('tools.textBehindImage.characters')}</span>
</Label>
<Input
type="text"
value={textSet.text}
onChange={(e) => updateTextSet(textSet.id, 'text', e.target.value)}
placeholder={t('tools.textBehindImage.textPlaceholder')}
/>
</InputWrapper>
<InputWrapper>
<Label>
{t('tools.textBehindImage.fontSize')}
<span>{textSet.fontSize}px</span>
</Label>
<Input
type="number"
value={textSet.fontSize}
onChange={(e) => updateTextSet(textSet.id, 'fontSize', Number(e.target.value))}
/>
</InputWrapper>
<InputWrapper>
<Label>
{t('tools.textBehindImage.fontWeight')}
<span>{textSet.fontWeight}</span>
</Label>
<Input
type="range"
min="100"
max="900"
step="100"
value={textSet.fontWeight}
onChange={(e) => updateTextSet(textSet.id, 'fontWeight', Number(e.target.value))}
/>
</InputWrapper>
<InputWrapper>
<Label>
{t('tools.textBehindImage.rotation')}
<span>{textSet.rotation}°</span>
</Label>
<Input
type="range"
min="-180"
max="180"
value={textSet.rotation}
onChange={(e) => updateTextSet(textSet.id, 'rotation', Number(e.target.value))}
/>
</InputWrapper>
<InputWrapper>
<Label>{t('tools.textBehindImage.color')}</Label>
<Input
type="color"
value={textSet.color}
onChange={(e) => updateTextSet(textSet.id, 'color', e.target.value)}
/>
</InputWrapper>
<InputWrapper>
<Label>
{t('tools.textBehindImage.opacity')}
<span>{textSet.opacity}</span>
</Label>
<Input
type="range"
min="0"
max="1"
step="0.1"
value={textSet.opacity}
onChange={(e) => updateTextSet(textSet.id, 'opacity', Number(e.target.value))}
/>
</InputWrapper>
<InputWrapper>
<Label>
{t('tools.textBehindImage.positionX')}
<span>{textSet.position.x}%</span>
</Label>
<Input
type="range"
min="0"
max="100"
value={textSet.position.x}
onChange={(e) => updateTextSet(textSet.id, 'position', {
...textSet.position,
x: Number(e.target.value)
})}
<>
{isLoading && <LoadingOverlay />}
<Container>
<ContentWrapper>
<ControlPanel>
<Title>{t('tools.textBehindImage.title')}</Title>
<SettingsGroup>
<GroupTitle>{t('tools.textBehindImage.imageUpload')}</GroupTitle>
<ImageUploadArea onClick={() => fileInputRef.current.click()}>
<input
type="file"
ref={fileInputRef}
style={{ display: 'none' }}
accept="image/*"
onChange={handleImageUpload}
/>
</InputWrapper>
<InputWrapper>
<Label>
{t('tools.textBehindImage.positionY')}
<span>{textSet.position.y}%</span>
</Label>
<Input
type="range"
min="0"
max="100"
value={textSet.position.y}
onChange={(e) => updateTextSet(textSet.id, 'position', {
...textSet.position,
y: Number(e.target.value)
})}
/>
</InputWrapper>
{t('tools.textBehindImage.uploadPrompt')}
</ImageUploadArea>
{/* 添加隐私提示 */}
<PrivacyNote>
{t('tools.textBehindImage.privacyNote')}
</PrivacyNote>
</SettingsGroup>
))}
</ControlPanel>
<PreviewArea className="preview-area">
<div className="preview-content">
{imageLoading ? (
<div className="loading-container">
<span> {t('tools.textBehindImage.processing')}</span>
</div>
) : selectedImage ? (
<div style={{
position: 'relative',
width: '100%',
height: '100%',
display: 'flex',
justifyContent: 'center',
alignItems: 'center'
}}>
<DownloadButton onClick={() => handleDownload('original')}>
{t('tools.textBehindImage.download')}
</DownloadButton>
<img
src={selectedImage}
alt="Background"
style={{
...calculateImageDimensions(),
objectFit: 'contain',
position: 'relative'
}}
/>
{textSets.map(textSet => (
<SettingsGroup key={textSet.id}>
<GroupTitle>{t('tools.textBehindImage.textSettings')}</GroupTitle>
<div style={{
position: 'absolute',
top: 0,
left: 0,
<InputWrapper>
<Label>
{t('tools.textBehindImage.text')}
<span>{textSet.text.length} {t('tools.textBehindImage.characters')}</span>
</Label>
<Input
type="text"
value={textSet.text}
onChange={(e) => updateTextSet(textSet.id, 'text', e.target.value)}
placeholder={t('tools.textBehindImage.textPlaceholder')}
/>
</InputWrapper>
<InputWrapper>
<Label>
{t('tools.textBehindImage.fontSize')}
<span>{textSet.fontSize}px</span>
</Label>
<Input
type="number"
value={textSet.fontSize}
onChange={(e) => updateTextSet(textSet.id, 'fontSize', Number(e.target.value))}
/>
</InputWrapper>
<InputWrapper>
<Label>
{t('tools.textBehindImage.fontWeight')}
<span>{textSet.fontWeight}</span>
</Label>
<Input
type="range"
min="100"
max="900"
step="100"
value={textSet.fontWeight}
onChange={(e) => updateTextSet(textSet.id, 'fontWeight', Number(e.target.value))}
/>
</InputWrapper>
<InputWrapper>
<Label>
{t('tools.textBehindImage.rotation')}
<span>{textSet.rotation}°</span>
</Label>
<Input
type="range"
min="-180"
max="180"
value={textSet.rotation}
onChange={(e) => updateTextSet(textSet.id, 'rotation', Number(e.target.value))}
/>
</InputWrapper>
<InputWrapper>
<Label>{t('tools.textBehindImage.color')}</Label>
<Input
type="color"
value={textSet.color}
onChange={(e) => updateTextSet(textSet.id, 'color', e.target.value)}
/>
</InputWrapper>
<InputWrapper>
<Label>
{t('tools.textBehindImage.opacity')}
<span>{textSet.opacity}</span>
</Label>
<Input
type="range"
min="0"
max="1"
step="0.1"
value={textSet.opacity}
onChange={(e) => updateTextSet(textSet.id, 'opacity', Number(e.target.value))}
/>
</InputWrapper>
<InputWrapper>
<Label>
{t('tools.textBehindImage.positionX')}
<span>{textSet.position.x}%</span>
</Label>
<Input
type="range"
min="0"
max="100"
value={textSet.position.x}
onChange={(e) => updateTextSet(textSet.id, 'position', {
...textSet.position,
x: Number(e.target.value)
})}
/>
</InputWrapper>
<InputWrapper>
<Label>
{t('tools.textBehindImage.positionY')}
<span>{textSet.position.y}%</span>
</Label>
<Input
type="range"
min="0"
max="100"
value={textSet.position.y}
onChange={(e) => updateTextSet(textSet.id, 'position', {
...textSet.position,
y: Number(e.target.value)
})}
/>
</InputWrapper>
</SettingsGroup>
))}
</ControlPanel>
<PreviewArea className="preview-area">
<div className="preview-content">
{imageLoading ? (
<div className="loading-container">
<span> {t('tools.textBehindImage.processing')}</span>
</div>
) : selectedImage ? (
<div style={{
position: 'relative',
width: '100%',
height: '100%',
pointerEvents: 'none'
display: 'flex',
justifyContent: 'center',
alignItems: 'center'
}}>
{textSets.map(textSet => {
const imageElement = document.querySelector('img[alt="Background"]');
const imageRect = imageElement?.getBoundingClientRect();
const imageWidth = imageRect?.width || 0;
// 计算文本的中心字符位置
const text = textSet.text;
const centerIndex = Math.floor(text.length / 2);
const leftPart = text.substring(0, centerIndex);
const centerChar = text.charAt(centerIndex);
const rightPart = text.substring(centerIndex + 1);
return (
<div
key={textSet.id}
style={{
position: 'absolute',
left: `${textSet.position.x}%`,
top: `${textSet.position.y}%`,
transform: `translate(-50%, -50%) rotate(${textSet.rotation}deg)`,
color: textSet.color,
fontSize: `${textSet.fontSize}px`,
fontFamily: 'Inter',
fontWeight: textSet.fontWeight,
opacity: textSet.opacity,
zIndex: 1,
whiteSpace: 'pre',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
transformOrigin: 'center',
}}
>
<span>{leftPart}</span>
<span style={{
display: 'inline-block',
position: 'relative'
}}>{centerChar}</span>
<span>{rightPart}</span>
</div>
);
})}
</div>
{removedBgImageUrl && (
<img
src={removedBgImageUrl}
alt="Foreground"
<DownloadButton onClick={() => handleDownload('original')}>
{t('tools.textBehindImage.download')}
</DownloadButton>
<img
src={selectedImage}
alt="Background"
style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
...calculateImageDimensions(),
objectFit: 'contain',
zIndex: 2
}}
position: 'relative'
}}
/>
)}
</div>
) : (
<div className="upload-prompt">
{t('tools.textBehindImage.noImage')}
</div>
)}
<canvas ref={canvasRef} style={{ display: 'none' }} />
</div>
</PreviewArea>
</ContentWrapper>
</Container>
<div style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none'
}}>
{textSets.map(textSet => {
const imageElement = document.querySelector('img[alt="Background"]');
const imageRect = imageElement?.getBoundingClientRect();
const imageWidth = imageRect?.width || 0;
// 计算文本的中心字符位置
const text = textSet.text;
const centerIndex = Math.floor(text.length / 2);
const leftPart = text.substring(0, centerIndex);
const centerChar = text.charAt(centerIndex);
const rightPart = text.substring(centerIndex + 1);
return (
<div
key={textSet.id}
style={{
position: 'absolute',
left: `${textSet.position.x}%`,
top: `${textSet.position.y}%`,
transform: `translate(-50%, -50%) rotate(${textSet.rotation}deg)`,
color: textSet.color,
fontSize: `${textSet.fontSize}px`,
fontFamily: 'Inter',
fontWeight: textSet.fontWeight,
opacity: textSet.opacity,
zIndex: 1,
whiteSpace: 'pre',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
transformOrigin: 'center',
}}
>
<span>{leftPart}</span>
<span style={{
display: 'inline-block',
position: 'relative'
}}>{centerChar}</span>
<span>{rightPart}</span>
</div>
);
})}
</div>
{removedBgImageUrl && (
<img
src={removedBgImageUrl}
alt="Foreground"
style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
...calculateImageDimensions(),
objectFit: 'contain',
zIndex: 2
}}
/>
)}
</div>
) : (
<div className="upload-prompt">
{t('tools.textBehindImage.noImage')}
</div>
)}
<canvas ref={canvasRef} style={{ display: 'none' }} />
</div>
</PreviewArea>
</ContentWrapper>
</Container>
</>
);
}
......
......@@ -2,6 +2,8 @@ import React, { useState } from 'react';
import styled from 'styled-components';
import { useTranslation } from '../js/i18n';
import SEO from './SEO';
import { usePageLoading } from '../hooks/usePageLoading';
import LoadingOverlay from './LoadingOverlay';
const Container = styled.div`
min-height: 100vh;
......@@ -167,6 +169,7 @@ const DiffHeader = styled(TitleLabel)`
function TextDiff() {
const { t } = useTranslation();
const isLoading = usePageLoading();
const [oldText, setOldText] = useState('');
const [newText, setNewText] = useState('');
......@@ -180,6 +183,7 @@ function TextDiff() {
return (
<>
{isLoading && <LoadingOverlay />}
<SEO
title={t('tools.textDiff.title')}
description={t('tools.textDiff.description')}
......
......@@ -2,6 +2,8 @@ import React, { useState, useCallback } from 'react';
import { useTranslation } from '../js/i18n';
import SEO from './SEO';
import styled from 'styled-components';
import { usePageLoading } from '../hooks/usePageLoading';
import LoadingOverlay from './LoadingOverlay';
// 复用相同的样式组件
const Container = styled.div`
......@@ -45,6 +47,7 @@ const Title = styled.h2`
function UrlEncoderDecoder() {
const { t } = useTranslation();
const isLoading = usePageLoading();
const [input, setInput] = useState('');
const [resultText, setResultText] = useState('');
const [isCopied, setIsCopied] = useState(false);
......@@ -81,6 +84,7 @@ function UrlEncoderDecoder() {
return (
<>
{isLoading && <LoadingOverlay />}
<SEO
title={t('tools.urlEncodeDecode.title')}
description={t('tools.urlEncodeDecode.description')}
......
import { useState, useEffect } from 'react';
export const usePageLoading = () => {
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
// Set loading to false after a short delay to ensure content is ready
const timer = setTimeout(() => {
setIsLoading(false);
}, 500);
return () => clearTimeout(timer);
}, []);
return isLoading;
};
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
export const useScrollToTop = () => {
const { pathname } = useLocation();
useEffect(() => {
window.scrollTo(0, 0);
}, [pathname]);
};
......@@ -7,6 +7,8 @@
"login": "Login",
"loginSubtitle": "Welcome to AI Toolbox, please log in for the full experience",
"logout": "Logout",
"copy": "Copy",
"copied": "Copied",
"dev-tools": {
"title": "Development Tools",
"description": "A collection of popular and efficient development tools, including JSON formatter, Base64 to image converter, online Diff tool, LaTeX renderer, and more. Designed to help developers handle everyday coding tasks quickly and boost development efficiency."
......
......@@ -99,7 +99,8 @@
"download": "Download Image",
"dragOrClick": "Drag and drop or click to upload",
"fileName": "File name",
"fileSize": "File size"
"fileSize": "File size",
"preview": "Preview"
},
"fisherai": {
"title": "FisherAI",
......@@ -117,7 +118,7 @@
"title": "Subtitle Generator",
"description": "Quickly generate multi-line subtitle images with customizable styles",
"uploadImage": "Upload Background Image",
"dragOrClick": "Drag and drop or click to upload",
"dropOrClick": "Drag and drop or click to upload",
"removeImage": "Remove Image",
"globalSettings": "Global Settings",
"fontColor": "Font Color",
......
......@@ -7,6 +7,8 @@
"login": "ログイン",
"loginSubtitle": "AIツールボックスへようこそ。フル体験のためにログインしてください",
"logout": "ログアウト",
"copy": "コピー",
"copied": "コピーされました",
"dev-tools": {
"title": "開発ツール",
"description": "JSONフォーマッター、Base64画像変換、オンラインDiffツール、LaTeXレンダリングなど、さまざまな人気で効率的な開発ツールを統合し、日常のコーディングタスクを迅速に処理し、開発効率を向上させます。"
......
......@@ -99,7 +99,8 @@
"download": "画像をダウンロード",
"dragOrClick": "画像をドラッグまたはクリックしてアップロード",
"fileName": "ファイル名",
"fileSize": "ファイルサイズ"
"fileSize": "ファイルサイズ",
"preview": "プレビュー"
},
"fisherai": {
"title": "FisherAI",
......
......@@ -7,6 +7,8 @@
"login": "로그인",
"loginSubtitle": "AI 툴��스에 오신 것을 환영합니다. 전체 경험을 위해 로그인해 주세요",
"logout": "로그아웃",
"copy": "복사",
"copied": "복사된 메시지",
"dev-tools": {
"title": "개발 도구",
"description": "JSON 포맷터, Base64 이미지 변환기, 온라인 Diff 도구, LaTeX 렌더러 등 인기 있고 효율적인 개발 도구를 통합하여, 일상적인 코딩 작업을 신속하게 처리하고 개발 효율성을 높입니다."
......
......@@ -100,7 +100,8 @@
"download": "이미지 다운로드",
"dragOrClick": "드래그 앤 드롭 또는 클릭하여 업로드",
"fileName": "파일 이름",
"fileSize": "파일 크기"
"fileSize": "파일 크기",
"preview": "미리보기"
},
"fisherai": {
"title": "FisherAI",
......
......@@ -7,6 +7,8 @@
"login": "登录",
"loginSubtitle": "欢迎使用 AI 工具箱,请登录以获得完整体验",
"logout": "退出登录",
"copy": "复制",
"copied": "已复制",
"dev-tools": {
"title": "开发工具",
"description": "集成多种热门高效的开发工具,包括JSON格式化、Base64转图片、在线Diff对比、LaTeX渲染等,帮助开发者快速处理日常编码任务,提升开发效率。"
......
......@@ -101,7 +101,8 @@
"download": "下载图片",
"dragOrClick": "拖拽或点击上传图片",
"fileName": "文件名",
"fileSize": "文件大小"
"fileSize": "文件大小",
"preview": "预览"
},
"fisherai": {
"title": "FisherAI",
......@@ -201,7 +202,7 @@
},
"textBehindImage": {
"title": "文字穿越图片",
"description": "在图片主体与背景之间添加文字,创造3D效果",
"description": "在图片主体与背景之间添加文字,3D效果",
"imageUpload": "图片上传",
"uploadPrompt": "点击或拖拽上传图片",
"textSettings": "文字设置",
......
import React from 'react';
import { Link } from 'react-router-dom';
import React, { useState, useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useTranslation } from '../js/i18n';
import SEO from '../components/SEO';
......@@ -25,10 +25,37 @@ const tools = [
const Home = () => {
const { t } = useTranslation();
const [loading, setLoading] = useState('');
const navigate = useNavigate();
useEffect(() => {
const handleClearLoading = () => {
setLoading('');
};
window.addEventListener('clearLoadingState', handleClearLoading);
return () => {
window.removeEventListener('clearLoadingState', handleClearLoading);
};
}, []);
const handleNavigate = (tool) => {
if (tool.external) {
window.open(tool.path, '_blank', 'noopener,noreferrer');
return;
}
setLoading(tool.id);
window.scrollTo(0, 0);
navigate(tool.path);
};
const renderToolLink = (tool) => {
const content = (
<div className="group flex items-center gap-4 p-6 bg-white rounded-xl shadow-lg hover:shadow-xl transform hover:-translate-y-1 transition-all duration-300">
<div className={`group flex items-center gap-4 p-6 bg-white rounded-xl shadow-lg hover:shadow-xl transform hover:-translate-y-1 transition-all duration-300 relative ${loading === tool.id ? 'pointer-events-none' : ''}`}>
{loading === tool.id && (
<div className="absolute inset-0 bg-white/80 rounded-xl flex items-center justify-center z-10">
<div className="w-8 h-8 border-4 border-indigo-500 border-t-transparent rounded-full animate-spin"></div>
</div>
)}
<img
src={tool.icon}
alt={`${t(`tools.${tool.id}.title`)} icon`}
......@@ -48,17 +75,15 @@ const Home = () => {
return tool.external ? (
<a
href={tool.path}
className="block"
target="_blank"
rel="noopener noreferrer"
onClick={() => handleNavigate(tool)}
className="block cursor-pointer"
>
{content}
</a>
) : (
<Link to={tool.path} className="block">
<div onClick={() => handleNavigate(tool)} className="block cursor-pointer">
{content}
</Link>
</div>
);
};
......
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