Commit 81afffcb authored by fisherdaddy's avatar fisherdaddy

feat: Add Image Annotator tool and update WechatFormatter title in multiple languages

parent d0e6a913
......@@ -31,6 +31,7 @@ const AnthropicTimeline = lazy(() => import('./components/AnthropicTimeline'));
const DrugsList = lazy(() => import('./components/DrugsList'));
const DeepSeekTimeline = lazy(() => import('./components/DeepSeekTimeline'));
const WechatFormatter = lazy(() => import('./components/WechatFormatter'));
const ImageAnnotator = lazy(() => import('./components/ImageAnnotator'));
function App() {
return (
......@@ -69,6 +70,7 @@ function App() {
<Route path="/background-remover" element={<BackgroundRemover />} />
<Route path="/deepseek-timeline" element={<DeepSeekTimeline />} />
<Route path="/wechat-formatter" element={<WechatFormatter />} />
<Route path="/image-annotator" element={<ImageAnnotator />} />
<Route path="*" element={<NotFound />} />
</Routes>
</Suspense>
......
import React, { useState, useRef, useEffect } from 'react';
import styled from 'styled-components';
import { useTranslation } from '../js/i18n';
import SEO from './SEO';
import DOMPurify from 'dompurify';
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 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 InputContainer = 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 TitleLabel = 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;
`;
const UploadSection = styled(Section)`
margin-bottom: 1rem;
`;
const CoordinatesSection = styled(Section)`
flex: 1;
display: flex;
flex-direction: column;
`;
const UploadInput = styled.input`
width: 100%;
padding: 10px;
border: 2px dashed rgba(99, 102, 241, 0.3);
border-radius: 8px;
background: rgba(255, 255, 255, 0.5);
transition: all 0.3s ease;
&:hover, &:focus {
border-color: rgba(99, 102, 241, 0.6);
background: rgba(255, 255, 255, 0.8);
}
`;
const UrlInput = styled.input`
width: 100%;
padding: 10px;
border: 1px solid rgba(99, 102, 241, 0.3);
border-radius: 8px;
background: rgba(255, 255, 255, 0.5);
transition: all 0.3s ease;
&:hover, &:focus {
border-color: rgba(99, 102, 241, 0.6);
outline: none;
}
`;
const CoordinatesEditor = styled.textarea`
width: 100%;
height: 200px;
padding: 1rem;
border: 1px solid rgba(99, 102, 241, 0.3);
border-radius: 8px;
background: rgba(255, 255, 255, 0.5);
font-family: 'SF Mono', monospace;
font-size: 14px;
line-height: 1.5;
resize: vertical;
color: #1a1a1a;
overflow-y: auto;
&:focus {
outline: none;
border-color: rgba(99, 102, 241, 0.6);
}
&::placeholder {
color: #64748b;
}
`;
const PreviewContainer = styled(InputContainer)`
position: relative;
min-height: 400px;
overflow: visible;
display: flex;
flex-direction: column;
`;
const ButtonsContainer = styled.div`
display: flex;
flex-direction: row;
justify-content: flex-end;
gap: 0.8rem;
margin-bottom: 1rem;
z-index: 20;
`;
const ImagePreview = styled.div`
position: relative;
margin: 0 auto;
max-width: 100%;
max-height: 100%;
overflow: auto;
flex: 1;
`;
const AnnotatedImage = styled.div`
position: relative;
display: inline-block;
`;
const Image = styled.img`
display: block;
max-width: 100%;
max-height: 600px;
`;
const BoundingBox = styled.div`
position: absolute;
border: 3px solid ${props => props.color || '#FF0000'};
background-color: ${props => props.color || '#FF0000'}20;
z-index: 10;
pointer-events: auto;
cursor: pointer;
opacity: ${props => props.isSelected ? 1 : props.isOtherSelected ? 0.3 : 1};
transition: opacity 0.2s ease;
`;
const BoxLabel = styled.span`
position: absolute;
${props => props.position === 'bottom' ? 'top: calc(100% + 4px);' : 'top: -24px;'}
left: 0;
background-color: ${props => props.color || '#FF0000'};
color: white;
padding: 2px 6px;
border-radius: 4px;
font-size: 12px;
font-weight: bold;
display: ${props => props.visible ? 'block' : 'none'};
z-index: 30;
`;
const InfoMessage = styled.div`
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: #64748b;
text-align: center;
padding: 2rem;
`;
const DownloadButton = styled.button`
background: linear-gradient(135deg, #6366F1 0%, #4F46E5 100%);
color: white;
padding: 0.5rem 1rem;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
transition: opacity 0.2s;
font-size: 0.9rem;
opacity: ${props => props.visible ? 1 : 0};
pointer-events: ${props => props.visible ? 'auto' : 'none'};
&:hover {
opacity: 0.9;
}
`;
const ResetButton = styled.button`
background: white;
color: #4F46E5;
padding: 0.5rem 1rem;
border: 1px solid #4F46E5;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
transition: all 0.2s;
font-size: 0.9rem;
opacity: ${props => props.visible ? 1 : 0};
pointer-events: ${props => props.visible ? 'auto' : 'none'};
&:hover {
background: #F5F7FF;
}
`;
// Box colors for different annotations
const COLORS = [
'#FF3B30', '#FF9500', '#FFCC00', '#34C759', '#5AC8FA',
'#007AFF', '#5856D6', '#AF52DE', '#FF2D55', '#A2845E'
];
function ImageAnnotator() {
const { t } = useTranslation();
const [imageUrl, setImageUrl] = useState('');
const [uploadedImage, setUploadedImage] = useState(null);
const [coordinates, setCoordinates] = useState('');
const [error, setError] = useState('');
const previewRef = useRef(null);
const imageRef = useRef(null);
const [imageSize, setImageSize] = useState({ width: 0, height: 0 });
const [parsedBoxes, setParsedBoxes] = useState([]);
const [selectedBoxId, setSelectedBoxId] = useState(null);
const isLoading = usePageLoading();
// Handle box selection
const handleBoxClick = (boxId) => {
setSelectedBoxId(selectedBoxId === boxId ? null : boxId);
};
// Handle image upload
const handleImageUpload = (e) => {
if (e.target.files && e.target.files[0]) {
const file = e.target.files[0];
setUploadedImage(URL.createObjectURL(file));
setImageUrl('');
}
};
// Handle image URL input
const handleImageUrlChange = (e) => {
setImageUrl(e.target.value);
setUploadedImage(null);
};
// Handle coordinates input
const handleCoordinatesChange = (e) => {
setCoordinates(e.target.value);
};
// Parse the coordinates when either the coordinates text or image changes
useEffect(() => {
if (!coordinates.trim()) {
setParsedBoxes([]);
setError('');
return;
}
try {
// Try to parse as JSON
let boxesArray;
try {
boxesArray = JSON.parse(coordinates);
} catch (e) {
// If not valid JSON, try to parse as plain text with numbers
boxesArray = coordinates
.split('\n')
.filter(line => line.trim())
.map(line => {
const nums = line.match(/\d+(\.\d+)?/g);
if (!nums || nums.length < 4) {
throw new Error(`Invalid format in line: ${line}`);
}
return nums.slice(0, 4).map(Number);
});
}
// Validate the structure
if (!Array.isArray(boxesArray)) {
throw new Error('Input must be an array of coordinates');
}
// Validate each box
boxesArray.forEach((box, index) => {
if (!Array.isArray(box) && typeof box !== 'object') {
throw new Error(`Box at index ${index} is not an array or object`);
}
let x1, y1, x2, y2;
if (Array.isArray(box)) {
[x1, y1, x2, y2] = box;
} else if (typeof box === 'object') {
// Support for different object formats
if ('x1' in box && 'y1' in box && 'x2' in box && 'y2' in box) {
x1 = box.x1;
y1 = box.y1;
x2 = box.x2;
y2 = box.y2;
} else if ('xmin' in box && 'ymin' in box && 'xmax' in box && 'ymax' in box) {
x1 = box.xmin;
y1 = box.ymin;
x2 = box.xmax;
y2 = box.ymax;
} else {
throw new Error(`Box at index ${index} has invalid object format`);
}
}
if (isNaN(x1) || isNaN(y1) || isNaN(x2) || isNaN(y2)) {
throw new Error(`Box at index ${index} has invalid coordinates`);
}
});
// Standardize to array format
const standardBoxes = boxesArray.map((box, index) => {
if (Array.isArray(box)) {
return {
id: index,
x1: box[0],
y1: box[1],
x2: box[2],
y2: box[3],
color: COLORS[index % COLORS.length]
};
} else {
return {
id: index,
x1: box.x1 || box.xmin,
y1: box.y1 || box.ymin,
x2: box.x2 || box.xmax,
y2: box.y2 || box.ymax,
color: COLORS[index % COLORS.length]
};
}
});
setParsedBoxes(standardBoxes);
setError('');
} catch (err) {
setParsedBoxes([]);
setError(err.message);
}
}, [coordinates, imageUrl, uploadedImage]);
// Update image size when image loads
const handleImageLoad = (e) => {
const img = e.target;
setImageSize({
width: img.naturalWidth,
height: img.naturalHeight
});
};
// Handle image clicks (to deselect)
const handleImageClick = (e) => {
// Only handle clicks directly on the image, not on boxes
if (e.target === imageRef.current) {
setSelectedBoxId(null);
}
};
// Handle download of annotated image
const handleDownload = async () => {
const previewElement = previewRef.current;
if (!previewElement) return;
try {
// Hide all coordinate labels before capture
const currentSelectedId = selectedBoxId;
setSelectedBoxId(null);
// Wait for React to update the DOM
await new Promise(resolve => setTimeout(resolve, 100));
const html2canvas = (await import('html2canvas')).default;
const canvas = await html2canvas(previewElement, {
scale: 2,
useCORS: true,
allowTaint: true,
logging: false,
});
// Restore selected box
setSelectedBoxId(currentSelectedId);
const link = document.createElement('a');
link.download = 'annotated-image.png';
link.href = canvas.toDataURL('image/png');
link.click();
} catch (error) {
console.error('Failed to export image:', error);
}
};
const currentImageUrl = uploadedImage || (imageUrl.trim() && imageUrl);
const hasImage = !!currentImageUrl;
const hasBoxes = parsedBoxes.length > 0;
return (
<>
{isLoading && <LoadingOverlay />}
<SEO
title={t('tools.imageAnnotator.title')}
description={t('tools.imageAnnotator.description')}
/>
<Container>
<ContentWrapper>
<InputContainer>
<TitleLabel>{t('tools.imageAnnotator.title')}</TitleLabel>
{/* Image upload section */}
<UploadSection>
<Label>{t('tools.imageAnnotator.uploadLabel')}</Label>
<UploadInput
type="file"
accept="image/*"
onChange={handleImageUpload}
/>
</UploadSection>
{/* Image URL section */}
<UploadSection>
<Label>{t('tools.imageAnnotator.urlLabel')}</Label>
<UrlInput
type="text"
value={imageUrl}
onChange={handleImageUrlChange}
placeholder={t('tools.imageAnnotator.urlPlaceholder')}
/>
</UploadSection>
{/* Coordinates input section */}
<CoordinatesSection>
<Label>
{t('tools.imageAnnotator.coordinatesLabel')}
</Label>
<CoordinatesEditor
value={coordinates}
onChange={handleCoordinatesChange}
placeholder={t('tools.imageAnnotator.coordinatesPlaceholder')}
/>
{error && (
<div style={{ color: 'red', fontSize: '14px', marginTop: '5px' }}>
{error}
</div>
)}
</CoordinatesSection>
</InputContainer>
<PreviewContainer>
<ButtonsContainer>
<DownloadButton
onClick={handleDownload}
visible={hasImage && hasBoxes}
>
{t('tools.imageAnnotator.downloadButton')}
</DownloadButton>
<ResetButton
onClick={() => setSelectedBoxId(null)}
visible={hasImage && hasBoxes}
>
{t('tools.imageAnnotator.resetView') || '恢复视图'}
</ResetButton>
</ButtonsContainer>
<ImagePreview ref={previewRef}>
{hasImage ? (
<AnnotatedImage>
<Image
src={currentImageUrl}
alt="Uploaded image"
ref={imageRef}
onLoad={handleImageLoad}
onClick={handleImageClick}
crossOrigin="anonymous"
/>
{hasBoxes && parsedBoxes.map((box) => {
// Determine label position based on box position
const isNearTop = box.y1 < 30;
return (
<BoundingBox
key={box.id}
color={box.color}
isSelected={selectedBoxId === box.id}
isOtherSelected={selectedBoxId !== null && selectedBoxId !== box.id}
onClick={() => handleBoxClick(box.id)}
style={{
left: `${box.x1}px`,
top: `${box.y1}px`,
width: `${box.x2 - box.x1}px`,
height: `${box.y2 - box.y1}px`
}}
>
<BoxLabel
color={box.color}
visible={selectedBoxId === box.id}
position={isNearTop ? 'bottom' : 'top'}
>
({box.x1},{box.y1})-({box.x2},{box.y2})
</BoxLabel>
</BoundingBox>
);
})}
</AnnotatedImage>
) : (
<InfoMessage>
<div>{t('tools.imageAnnotator.noImageMessage')}</div>
</InfoMessage>
)}
</ImagePreview>
</PreviewContainer>
</ContentWrapper>
</Container>
</>
);
}
export default ImageAnnotator;
\ No newline at end of file
......@@ -19,7 +19,7 @@
"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."
},
"blog": {
"title": "Blog",
"title": "AI News",
"description": "Offers the latest tech insights, development tips, and AI product reviews to help developers stay updated with cutting-edge information and improve their skills."
},
"ai-products": {
......
......@@ -239,5 +239,29 @@
"items": "items",
"allCategories": "All Categories",
"noResults": "No results found"
},
"wechatFormatter": {
"title": "WeChat Article Formatter",
"description": "Markdown and HTML content can be converted to WeChat format at once",
"input": "Input Content",
"output": "Output Content",
"inputPlaceholder": "Enter the text you want to format for WeChat here",
"format": "Format",
"copyOutput": "Copy Output",
"copiedMessage": "Copied to clipboard",
"copy": "Copy",
"copied": "Copied!"
},
"imageAnnotator": {
"title": "Image Annotator",
"description": "Upload an image and visualize bounding boxes using coordinates",
"uploadLabel": "Upload Image",
"urlLabel": "Or Image URL",
"urlPlaceholder": "https://example.com/image.jpg",
"coordinatesLabel": "Bounding Box Coordinates (x1,y1,x2,y2)",
"coordinatesPlaceholder": "Enter coordinates in JSON format: [[x1,y1,x2,y2], ...] or one box per line",
"downloadButton": "Download",
"noImageMessage": "Upload an image or provide an image URL to begin",
"resetView": "Reset View"
}
}
......@@ -19,8 +19,8 @@
"description": "手書きフォント生成、Markdown画像変換、有名な引用句の画像化など、実用的な画像処理ツールを多数集め、画像編集とクリエイティブデザインの作業を簡単にします。"
},
"blog": {
"title": "ブログ",
"description": "最新の技術情報、開発のヒント、AI製品のレビューなどを提供し、開発者が最新のトレンドに追いつき、スキルを向上させることをサポートします。"
"title": "AIニュース",
"description": "最新の技術共有、開発経験、AI製品レビューなどのコンテンツを提供し、開発者が最先端の情報を入手してスキルを向上できるようサポートします。"
},
"ai-products": {
"title": "AI製品",
......
......@@ -239,5 +239,26 @@
"items": "項目",
"allCategories": "すべてのカテゴリ",
"noResults": "検索結果がありません"
},
"wechatFormatter": {
"title": "WeChat フォーマッター",
"description": "Markdown および HTML コンテンツを一度に WeChat フォーマットに変換",
"input": "入力内容",
"output": "出力内容",
"inputPlaceholder": "WeChat用に整列するテキストを入力してください",
"format": "フォーマット",
"copyOutput": "出力をコピー",
"copiedMessage": "コピーしました",
"copy": "コピー",
"copied": "コピーしました!"
},
"imageAnnotator": {
"title": "画像アノテーションツール",
"description": "画像に境界ボックスを追加して編集できます",
"uploadImage": "画像をアップロード",
"dropOrClick": "ドラッグまたはクリックして画像をアップロード",
"downloadButton": "ダウンロード",
"noImageMessage": "画像をアップロードするか、画像URLを提供して開始してください",
"resetView": "ビューをリセット"
}
}
......@@ -19,8 +19,8 @@
"description": "손글씨 폰트 생성기, Markdown 이미지 변환기, 명언 이미지 생성기 등 다양한 실용적인 이미지 처리 도구를 제공하여, 이미지 편집과 창의적인 디자인 작업을 쉽게 수행할 수 있습니다."
},
"blog": {
"title": "블로그",
"description": "최신 기술 정보, 개발 팁, AI 제품 리뷰 등을 제공하여 개발자가 최신 트렌드에 맞춰 정보를 얻고 기술을 향상시킬 수 있도록 지원합니다."
"title": "AI 뉴스",
"description": "최신 기술 공유, 개발 경험, AI 제품 리뷰 등의 콘텐츠를 제공하여 개발자가 최신 정보를 얻고 기술을 향상시킬 수 있도록 돕습니다."
},
"ai-products": {
"title": "AI 제품",
......
......@@ -240,5 +240,26 @@
"items": "항목",
"allCategories": "모든 카테고리",
"noResults": "검색 결과가 없습니다"
},
"wechatFormatter": {
"title": "WeChat 서식 도우미",
"description": "Markdown 및 HTML 콘텐츠를 한 번에 WeChat 서식으로 변환",
"input": "입력 내용",
"output": "출력 내용",
"inputPlaceholder": "WeChat용으로 정렬할 텍스트를 입력하세요",
"format": "서식 지정",
"copyOutput": "결과 복사",
"copiedMessage": "클립보드에 복사됨",
"copy": "복사",
"copied": "복사 완료!"
},
"imageAnnotator": {
"title": "이미지 어노테이터",
"description": "이미지에 경계 상자를 추가하고 편집합니다",
"uploadImage": "이미지 업로드",
"dropOrClick": "드래그하거나 클릭하여 이미지 업로드",
"downloadButton": "다운로드",
"noImageMessage": "이미지를 업로드하거나 이미지 URL을 제공하세요",
"resetView": "뷰 초기화"
}
}
......@@ -19,7 +19,7 @@
"description": "集成多款实用的图片处理工具,包括手写字体生成器、Markdown转图片、名人名言转图片等,轻松完成图片编辑与创意设计工作。"
},
"blog": {
"title": "博客",
"title": "AI 资讯",
"description": "提供最新技术分享、开发经验、AI产品评测等内容,帮助开发者获取前沿资讯,提升技能。"
},
"ai-products": {
......
......@@ -226,24 +226,44 @@
"description": "Anthropic 公司重要产品及事件发布时间表"
},
"deepSeekTimeline": {
"title": "DeepSeek 模型发布",
"description": "DeepSeek 模型发布时间一览"
"title": "DeepSeek 模型发布记录",
"description": "DeepSeek 重要模型发布及事件时间线"
},
"drugsList": {
"title": "中国进口原研药目录",
"description": "药品名称、生产厂商和类别信息",
"searchPlaceholder": "搜索药品名称或生产厂商...",
"title": "国内进口原研药目录",
"description": "药品名称、厂家和分类信息查询",
"searchPlaceholder": "搜索药品名称或厂家...",
"drugName": "药品名称",
"manufacturer": "生产厂",
"manufacturer": "生产厂",
"sourceTitle": "数据来源",
"sourceUrl": "mRNA福星情报局",
"sourceUrl": "mRNA幸运情报局",
"showing": "显示",
"items": "个项目",
"allCategories": "所有类别",
"noResults": "没有找到相关结果"
"items": "",
"allCategories": "所有分类",
"noResults": "未找到结果"
},
"wechatFormatter": {
"title": "微信公众号排版",
"description": "将 Markdown 或 HTML 转换为公众号排版"
"title": "微信公众号排版助手",
"description": "Markdown、 HTML 格式内容一键即可转为微信公众号排版",
"input": "输入内容",
"output": "输出内容",
"inputPlaceholder": "在此输入需要微信排版的文本",
"format": "格式化",
"copyOutput": "复制结果",
"copiedMessage": "已复制到剪贴板",
"copy": "复制",
"copied": "已复制!"
},
"imageAnnotator": {
"title": "图像标注工具",
"description": "上传图片并可视化显示边界框",
"uploadLabel": "上传图片",
"urlLabel": "或图片URL",
"urlPlaceholder": "https://example.com/image.jpg",
"coordinatesLabel": "边界框坐标 (x1,y1,x2,y2)",
"coordinatesPlaceholder": "输入坐标,JSON格式:[[x1,y1,x2,y2], ...] 或每行输入一个框",
"downloadButton": "下载",
"noImageMessage": "上传图片或提供图片URL开始",
"resetView": "重置视图"
}
}
......@@ -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: 'wechatFormatter', icon: '/assets/icon/editor.png', path: '/wechat-formatter' },
{ id: 'imageAnnotator', icon: '/assets/icon/image-annotator.png', path: '/image-annotator' },
{ 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' },
......@@ -22,7 +24,6 @@ const tools = [
{ id: 'deepSeekTimeline', icon: '/assets/icon/deepseek_small.jpg', path: '/deepseek-timeline' },
{ id: 'modelPrice', icon: '/assets/icon/model-price.svg', path: '/llm-model-price' },
{ id: 'drugsList', icon: '/assets/icon/drugs.svg', path: '/drugs-list' },
{ id: 'wechatFormatter', icon: '/assets/icon/editor.png', path: '/wechat-formatter' },
{ id: 'fisherai', icon: '/assets/icon/fisherai.png', path: 'https://chromewebstore.google.com/detail/fisherai-your-best-summar/ipfiijaobcenaibdpaacbbpbjefgekbj', external: true }
];
......
......@@ -13,6 +13,7 @@ const tools = [
{ id: 'imageWatermark', icon: '/assets/icon/image-watermark.png', path: '/image-watermark' },
{ id: 'imageBackgroundRemover', icon: '/assets/icon/image-background-remover.png', path: '/background-remover' },
{ id: 'textBehindImage', icon: '/assets/icon/text-behind-image.png', path: '/text-behind-image' },
{ id: 'imageAnnotator', icon: '/assets/icon/image-annotator.png', path: '/image-annotator' },
];
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