Commit 6a9748c1 authored by fisherdaddy's avatar fisherdaddy

feature: 新增图像水印工具

parent 9fe8a7e8
......@@ -2,3 +2,5 @@ node_modules/
package-lock.json
dist/
src/.DS_Store
public/.DS_Store
.DS_Store
\ No newline at end of file
No preview for this file type
public/assets/icon/handwrite.png

324 KB | W: | H:

public/assets/icon/handwrite.png

188 KB | W: | H:

public/assets/icon/handwrite.png
public/assets/icon/handwrite.png
public/assets/icon/handwrite.png
public/assets/icon/handwrite.png
  • 2-up
  • Swipe
  • Onion skin
public/assets/icon/latex2image.png

347 KB | W: | H:

public/assets/icon/latex2image.png

182 KB | W: | H:

public/assets/icon/latex2image.png
public/assets/icon/latex2image.png
public/assets/icon/latex2image.png
public/assets/icon/latex2image.png
  • 2-up
  • Swipe
  • Onion skin
public/assets/icon/quotecard.png

396 KB | W: | H:

public/assets/icon/quotecard.png

178 KB | W: | H:

public/assets/icon/quotecard.png
public/assets/icon/quotecard.png
public/assets/icon/quotecard.png
public/assets/icon/quotecard.png
  • 2-up
  • Swipe
  • Onion skin
public/assets/icon/url-endecode.png

234 KB | W: | H:

public/assets/icon/url-endecode.png

193 KB | W: | H:

public/assets/icon/url-endecode.png
public/assets/icon/url-endecode.png
public/assets/icon/url-endecode.png
public/assets/icon/url-endecode.png
  • 2-up
  • Swipe
  • Onion skin
......@@ -24,6 +24,7 @@ const LatexToImage = lazy(() => import('./components/LatexToImage'));
const TextDiff = lazy(() => import('./components/TextDiff'));
const SubtitleGenerator = lazy(() => import('./components/SubtitleGenerator'));
const ImageCompressor = lazy(() => import('./components/ImageCompressor'));
const ImageWatermark = lazy(() => import('./components/ImageWatermark'));
function App() {
return (
......@@ -54,6 +55,7 @@ function App() {
<Route path="/text-diff" element={<TextDiff />} />
<Route path="/subtitle-to-image" element={<SubtitleGenerator />} />
<Route path="/image-compressor" element={<ImageCompressor />} />
<Route path="/image-watermark" element={<ImageWatermark />} />
<Route path="*" element={<NotFound />} />
......
import React, { useState, useRef, useCallback, useEffect } from 'react';
import styled from 'styled-components';
import { useTranslation } from '../js/i18n';
import SEO from './SEO';
// 复用 MarkdownToImage 的基础容器样式
const Container = styled.div`
min-height: 100vh;
background: linear-gradient(135deg, #f5f7ff 0%, #ffffff 100%);
padding: 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 ContentWrapper = styled.div`
display: flex;
gap: 2rem;
max-width: 1400px;
margin: 0 auto;
position: relative;
z-index: 1;
@media (max-width: 768px) {
flex-direction: column;
}
`;
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);
display: flex;
flex-direction: column;
gap: 1rem;
`;
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;
`;
const Section = styled.div`
display: flex;
flex-direction: column;
gap: 0.5rem;
`;
const Label = styled.label`
font-size: 1rem;
color: #333333;
margin-bottom: 0.5rem;
`;
const Input = styled.input`
padding: 0.5rem;
border: 1px solid #dadce0;
border-radius: 4px;
font-size: 14px;
`;
const Select = styled.select`
padding: 0.5rem;
border: 1px solid #dadce0;
border-radius: 4px;
font-size: 14px;
`;
const ImagePreview = styled.div`
flex: 1;
background: white;
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);
position: relative;
min-height: 400px;
display: flex;
justify-content: center;
align-items: center;
img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
`;
const DownloadButton = styled.button`
background: linear-gradient(135deg, #6366F1 0%, #4F46E5 100%);
color: white;
border: none;
padding: 1rem;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
margin-top: 1rem;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.2);
}
&:disabled {
background: #ccc;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
`;
const FileInput = styled.input`
display: none;
`;
const UploadButton = styled.div`
border: 2px dashed rgba(99, 102, 241, 0.2);
border-radius: 12px;
padding: 2rem;
text-align: center;
cursor: pointer;
transition: all 0.3s ease;
background: rgba(255, 255, 255, 0.5);
&:hover {
border-color: rgba(99, 102, 241, 0.4);
background: rgba(99, 102, 241, 0.05);
}
`;
function ImageWatermark() {
const { t } = useTranslation();
const [image, setImage] = useState(null);
const [watermarkText, setWatermarkText] = useState('');
const [watermarkImage, setWatermarkImage] = useState(null);
const [watermarkType, setWatermarkType] = useState('text'); // 'text' or 'image'
const [watermarkSettings, setWatermarkSettings] = useState({
fontSize: 100,
opacity: 0.5,
rotation: 45,
position: 'center', // center, topLeft, topRight, bottomLeft, bottomRight
color: '#FF0000',
spacing: 100, // 水印之间的间距
});
const canvasRef = useRef(null);
const fileInputRef = useRef(null);
const watermarkFileInputRef = useRef(null);
const [previewImage, setPreviewImage] = useState(null);
const handleImageUpload = (e) => {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (e) => setImage(e.target.result);
reader.readAsDataURL(file);
}
};
const handleWatermarkImageUpload = (e) => {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (e) => setWatermarkImage(e.target.result);
reader.readAsDataURL(file);
}
};
const applyWatermark = useCallback(() => {
if (!image) return;
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
const img = new Image();
img.onload = () => {
// 设置画布尺寸为图片尺寸
canvas.width = img.width;
canvas.height = img.height;
// 清除画布
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 绘制原始图片
ctx.drawImage(img, 0, 0);
if (watermarkType === 'text' && watermarkText) {
// 设置水印文字样式
ctx.font = `${watermarkSettings.fontSize}px Arial`;
ctx.fillStyle = watermarkSettings.color + Math.round(watermarkSettings.opacity * 255).toString(16).padStart(2, '0');
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
// 计算水印位置
let x, y;
switch (watermarkSettings.position) {
case 'topLeft':
x = watermarkSettings.fontSize * 2;
y = watermarkSettings.fontSize * 2;
break;
case 'topRight':
x = canvas.width - watermarkSettings.fontSize * 2;
y = watermarkSettings.fontSize * 2;
break;
case 'bottomLeft':
x = watermarkSettings.fontSize * 2;
y = canvas.height - watermarkSettings.fontSize * 2;
break;
case 'bottomRight':
x = canvas.width - watermarkSettings.fontSize * 2;
y = canvas.height - watermarkSettings.fontSize * 2;
break;
default: // center
x = canvas.width / 2;
y = canvas.height / 2;
}
// 保存当前状态
ctx.save();
// 移动到水印位置并旋转
ctx.translate(x, y);
ctx.rotate((watermarkSettings.rotation * Math.PI) / 180);
// 绘制水印文字
ctx.fillText(watermarkText, 0, 0);
// 恢复状态
ctx.restore();
} else if (watermarkType === 'image' && watermarkImage) {
const watermark = new Image();
watermark.onload = () => {
// 计算水印大小
const watermarkSize = Math.min(canvas.width, canvas.height) * 0.2;
const ratio = watermarkSize / Math.max(watermark.width, watermark.height);
const watermarkWidth = watermark.width * ratio;
const watermarkHeight = watermark.height * ratio;
// 设置透明度
ctx.globalAlpha = watermarkSettings.opacity;
// 计算水印位置
let x, y;
switch (watermarkSettings.position) {
case 'topLeft':
x = watermarkWidth / 2;
y = watermarkHeight / 2;
break;
case 'topRight':
x = canvas.width - watermarkWidth / 2;
y = watermarkHeight / 2;
break;
case 'bottomLeft':
x = watermarkWidth / 2;
y = canvas.height - watermarkHeight / 2;
break;
case 'bottomRight':
x = canvas.width - watermarkWidth / 2;
y = canvas.height - watermarkHeight / 2;
break;
default: // center
x = canvas.width / 2;
y = canvas.height / 2;
}
// 绘制水印图片
ctx.drawImage(
watermark,
x - watermarkWidth / 2,
y - watermarkHeight / 2,
watermarkWidth,
watermarkHeight
);
// 更新预览图片
setPreviewImage(canvas.toDataURL('image/png'));
};
watermark.src = watermarkImage;
}
// 如果是文字水印,立即更新预览图片
if (watermarkType === 'text') {
setPreviewImage(canvas.toDataURL('image/png'));
}
};
img.src = image;
}, [image, watermarkType, watermarkText, watermarkImage, watermarkSettings]);
// 当相关状态改变时,自动应用水印
useEffect(() => {
if (image) {
applyWatermark();
}
}, [image, watermarkType, watermarkText, watermarkImage, watermarkSettings, applyWatermark]);
const handleDownload = () => {
if (previewImage) {
const link = document.createElement('a');
link.download = 'watermarked-image.png';
link.href = previewImage;
link.click();
}
};
return (
<>
<SEO
title={t('tools.imageWatermark.title')}
description={t('tools.imageWatermark.description')}
/>
<Container>
<ContentWrapper>
<ControlPanel>
<Title>{t('tools.imageWatermark.title')}</Title>
<Section>
<Label>{t('tools.imageWatermark.uploadImage')}</Label>
<UploadButton onClick={() => fileInputRef.current.click()}>
<FileInput
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleImageUpload}
/>
{t('tools.imageWatermark.dropOrClick')}
</UploadButton>
</Section>
<Section>
<Label>{t('tools.imageWatermark.watermarkType')}</Label>
<Select
value={watermarkType}
onChange={(e) => setWatermarkType(e.target.value)}
>
<option value="text">{t('tools.imageWatermark.textWatermark')}</option>
<option value="image">{t('tools.imageWatermark.imageWatermark')}</option>
</Select>
</Section>
{watermarkType === 'text' ? (
<Section>
<Label>{t('tools.imageWatermark.watermarkText')}</Label>
<Input
type="text"
value={watermarkText}
onChange={(e) => setWatermarkText(e.target.value)}
placeholder={t('tools.imageWatermark.watermarkTextPlaceholder')}
/>
</Section>
) : (
<Section>
<Label>{t('tools.imageWatermark.watermarkImage')}</Label>
<UploadButton onClick={() => watermarkFileInputRef.current.click()}>
<FileInput
ref={watermarkFileInputRef}
type="file"
accept="image/*"
onChange={handleWatermarkImageUpload}
/>
{t('tools.imageWatermark.uploadWatermark')}
</UploadButton>
</Section>
)}
<Section>
<Label>{t('tools.imageWatermark.opacity')}</Label>
<Input
type="range"
min="0"
max="1"
step="0.1"
value={watermarkSettings.opacity}
onChange={(e) => setWatermarkSettings({
...watermarkSettings,
opacity: parseFloat(e.target.value)
})}
/>
</Section>
{watermarkType === 'text' && (
<>
<Section>
<Label>{t('tools.imageWatermark.fontSize')}</Label>
<Input
type="number"
value={watermarkSettings.fontSize}
onChange={(e) => setWatermarkSettings({
...watermarkSettings,
fontSize: parseInt(e.target.value)
})}
/>
</Section>
<Section>
<Label>{t('tools.imageWatermark.rotation')}</Label>
<Input
type="range"
min="0"
max="360"
value={watermarkSettings.rotation}
onChange={(e) => setWatermarkSettings({
...watermarkSettings,
rotation: parseInt(e.target.value)
})}
/>
</Section>
<Section>
<Label>{t('tools.imageWatermark.color')}</Label>
<Input
type="color"
value={watermarkSettings.color}
onChange={(e) => setWatermarkSettings({
...watermarkSettings,
color: e.target.value
})}
/>
</Section>
</>
)}
<Section>
<Label>{t('tools.imageWatermark.position')}</Label>
<Select
value={watermarkSettings.position}
onChange={(e) => setWatermarkSettings({
...watermarkSettings,
position: e.target.value
})}
>
<option value="center">{t('tools.imageWatermark.positions.center')}</option>
<option value="topLeft">{t('tools.imageWatermark.positions.topLeft')}</option>
<option value="topRight">{t('tools.imageWatermark.positions.topRight')}</option>
<option value="bottomLeft">{t('tools.imageWatermark.positions.bottomLeft')}</option>
<option value="bottomRight">{t('tools.imageWatermark.positions.bottomRight')}</option>
</Select>
</Section>
<DownloadButton
onClick={handleDownload}
disabled={!image || (watermarkType === 'text' && !watermarkText) || (watermarkType === 'image' && !watermarkImage)}
>
{t('tools.imageWatermark.download')}
</DownloadButton>
</ControlPanel>
<ImagePreview>
<canvas ref={canvasRef} style={{ display: 'none' }} />
{previewImage ? (
<img src={previewImage} alt="Preview" />
) : image ? (
<img src={image} alt="Original" />
) : (
<div>{t('tools.imageWatermark.noImage')}</div>
)}
</ImagePreview>
</ContentWrapper>
</Container>
</>
);
}
export default ImageWatermark;
\ No newline at end of file
......@@ -158,5 +158,32 @@
"maxImagesHint": "You can upload a maximum of 10 images",
"recompress": "Recompress",
"compressionSettings": "Compression Settings"
}
},
"imageWatermark": {
"title": "Image Watermark",
"description": "Add text or image watermark to the image",
"uploadImage": "Upload Image",
"dropOrClick": "Drag or click to upload image",
"watermarkType": "Watermark Type",
"textWatermark": "Text Watermark",
"imageWatermark": "Image Watermark",
"watermarkText": "Watermark Text",
"watermarkTextPlaceholder": "Please enter watermark text",
"watermarkImage": "Watermark Image",
"uploadWatermark": "Upload Watermark Image",
"opacity": "Opacity",
"fontSize": "Font Size",
"rotation": "Rotation Angle",
"color": "Color",
"position": "Position",
"positions": {
"center": "Center",
"topLeft": "Top Left",
"topRight": "Top Right",
"bottomLeft": "Bottom Left",
"bottomRight": "Bottom Right"
},
"download": "Download Image",
"noImage": "Please upload an image"
}
}
\ No newline at end of file
......@@ -158,5 +158,32 @@
"maxImagesHint": "最大10枚の画像をアップロードできます",
"recompress": "再圧縮",
"compressionSettings": "圧縮設定"
},
"imageWatermark": {
"title": "画像の透かし",
"description": "画像にテキストまたは画像の透かしを追加",
"uploadImage": "画像をアップロード",
"dropOrClick": "ドラッグまたはクリックして画像をアップロード",
"watermarkType": "透かしの種類",
"textWatermark": "テキストの透かし",
"imageWatermark": "画像の透かし",
"watermarkText": "透かしテキスト",
"watermarkTextPlaceholder": "透かしテキストを入力してください",
"watermarkImage": "透かし画像",
"uploadWatermark": "透かし画像をアップロード",
"opacity": "不透明度",
"fontSize": "フォントサイズ",
"rotation": "回転角度",
"color": "カラー",
"position": "位置",
"positions": {
"center": "中央",
"topLeft": "左上",
"topRight": "右上",
"bottomLeft": "左下",
"bottomRight": "右下"
},
"download": "画像をダウンロード",
"noImage": "画像をアップロードしてください"
}
}
\ No newline at end of file
......@@ -159,5 +159,32 @@
"maxImagesHint": "최대 10개의 이미지를 업로드할 수 있습니다",
"recompress": "재압축",
"compressionSettings": "압축 설정"
},
"imageWatermark": {
"title": "이미지 워터마크",
"description": "이미지에 텍스트 또는 이미지 워터마크 추가",
"uploadImage": "이미지 업로드",
"dropOrClick": "드래그하거나 클릭하여 이미지 업로드",
"watermarkType": "워터마크 유형",
"textWatermark": "텍스트 워터마크",
"imageWatermark": "이미지 워터마크",
"watermarkText": "워터마크 텍스트",
"watermarkTextPlaceholder": "워터마크 텍스트를 입력하세요",
"watermarkImage": "워터마크 이미지",
"uploadWatermark": "워터마크 이미지 업로드",
"opacity": "불투명도",
"fontSize": "글꼴 크기",
"rotation": "회전 각도",
"color": "색상",
"position": "위치",
"positions": {
"center": "가운데",
"topLeft": "왼쪽 위",
"topRight": "오른쪽 위",
"bottomLeft": "왼쪽 아래",
"bottomRight": "오른쪽 아래"
},
"download": "이미지 다운로드",
"noImage": "이미지를 업로드하세요"
}
}
\ No newline at end of file
......@@ -113,7 +113,7 @@
"newPlaceholder": "在此输入新文本..."
},
"subtitleGenerator": {
"title": "字幕拼接工具",
"title": "字幕拼接",
"description": "快速生成多行字幕图片,支持自定义样式",
"uploadImage": "上传背景图片",
"removeImage": "移除图片",
......@@ -157,5 +157,32 @@
"maxImagesHint": "最多可上传 10 张图片",
"recompress": "重新压缩",
"compressionSettings": "压缩设置"
},
"imageWatermark": {
"title": "图片水印",
"description": "为图片添加文字或图片水印",
"uploadImage": "上传图片",
"dropOrClick": "拖拽或点击上传图片",
"watermarkType": "水印类型",
"textWatermark": "文字水印",
"imageWatermark": "图片水印",
"watermarkText": "水印文字",
"watermarkTextPlaceholder": "请输入水印文字",
"watermarkImage": "水印图片",
"uploadWatermark": "上传水印图片",
"opacity": "透明度",
"fontSize": "字体大小",
"rotation": "旋转角度",
"color": "颜色",
"position": "位置",
"positions": {
"center": "居中",
"topLeft": "左上角",
"topRight": "右上角",
"bottomLeft": "左下角",
"bottomRight": "右下角"
},
"download": "下载图片",
"noImage": "请上传图片"
}
}
......@@ -9,6 +9,7 @@ const tools = [
{ id: 'markdown2image', icon: '/assets/icon/markdown2image.png', path: '/markdown-to-image' },
{ id: 'subtitleGenerator', icon: '/assets/icon/subtitle2image.png', path: '/subtitle-to-image' },
{ id: 'imageCompressor', icon: '/assets/icon/image-compressor.png', path: '/image-compressor' },
{ id: 'imageWatermark', icon: '/assets/icon/image-watermark.png', path: '/image-watermark' },
{ id: 'latex2image', icon: '/assets/icon/latex2image.png', path: '/latex-to-image' },
{ id: 'jsonFormatter', icon: '/assets/icon/json-format.png', path: '/json-formatter' },
......
......@@ -10,7 +10,7 @@ const tools = [
{ id: 'latex2image', icon: '/assets/icon/latex2image.png', path: '/latex-to-image' },
{ id: 'subtitleGenerator', icon: '/assets/icon/subtitle2image.png', path: '/subtitle-to-image' },
{ id: 'imageCompressor', icon: '/assets/icon/image-compressor.png', path: '/image-compressor' },
{ id: 'imageWatermark', icon: '/assets/icon/image-watermark.png', path: '/image-watermark' },
];
const ImageTools = () => {
......
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