| | import React, { useEffect, useState, useRef, useImperativeHandle, forwardRef } from 'react'; |
| | import { Wrench, Trash2, Circle as CircleIcon, Hexagon, Square as SquareIcon, ChevronDown, ChevronUp, Sparkles, Loader } from 'lucide-react'; |
| |
|
| | type ShapeType = 'rect' | 'circle' | 'polygon'; |
| |
|
| | interface Point { |
| | x: number; |
| | y: number; |
| | } |
| |
|
| | interface Annotation { |
| | id: string; |
| | type: ShapeType; |
| | x: number; |
| | y: number; |
| | width: number; |
| | height: number; |
| | color: string; |
| | label?: string; |
| | points?: Point[]; |
| | source?: 'manual' | 'ai'; |
| | identified?: boolean; |
| | accepted?: boolean; |
| | } |
| |
|
| | interface ImageAnnotatorProps { |
| | imageUrl?: string; |
| | imageUrls?: string[]; |
| | onAnnotationsChange?: (annotations: Annotation[]) => void; |
| | onAIAssist?: () => Promise<void>; |
| | isAILoading?: boolean; |
| | } |
| |
|
| | export interface ImageAnnotatorHandle { |
| | addAIAnnotations: (aiAnnotations: Annotation[]) => void; |
| | setImageIndex: (index: number) => void; |
| | clearAIAnnotations: () => void; |
| | clearAllAnnotations: () => void; |
| | resetViewport: () => void; |
| | waitForImageReady: () => Promise<void>; |
| | } |
| |
|
| | const ImageAnnotatorComponent = forwardRef<ImageAnnotatorHandle, ImageAnnotatorProps>(({ imageUrl, imageUrls, onAnnotationsChange, onAIAssist, isAILoading: externalAILoading = false }, ref) => { |
| | const canvasRef = useRef<HTMLCanvasElement | null>(null); |
| | const containerRef = useRef<HTMLDivElement | null>(null); |
| | const imageReadyResolveRef = useRef<(() => void) | null>(null); |
| | const cachedImagesRef = useRef<Record<string, HTMLImageElement>>({}); |
| | const pendingDrawAnimationRef = useRef<number | null>(null); |
| | const lastDrawStateRef = useRef<string>(''); |
| | const [annotations, setAnnotations] = useState<Annotation[]>([]); |
| | const [tool, setTool] = useState<ShapeType>('rect'); |
| | const [color, setColor] = useState('#05998c'); |
| | const [labelInput, setLabelInput] = useState(''); |
| | const [isDrawing, setIsDrawing] = useState(false); |
| | const [startPoint, setStartPoint] = useState<Point | null>(null); |
| | const [currentAnnotation, setCurrentAnnotation] = useState<Annotation | null>(null); |
| | const [imageLoaded, setImageLoaded] = useState(false); |
| | const [imageDimensions, setImageDimensions] = useState({ width: 0, height: 0 }); |
| | const [polygonPoints, setPolygonPoints] = useState<Point[]>([]); |
| | const [selectedImageIndex, setSelectedImageIndex] = useState(0); |
| | const [isAnnotationsOpen, setIsAnnotationsOpen] = useState(true); |
| | const [isLabelDropdownOpen, setIsLabelDropdownOpen] = useState(false); |
| | const [isAIAssistEnabled, setIsAIAssistEnabled] = useState(false); |
| | const [internalAILoading, setInternalAILoading] = useState(false); |
| | |
| | |
| | const isAILoading = externalAILoading || internalAILoading; |
| |
|
| | |
| | const [editingId, setEditingId] = useState<string | null>(null); |
| | const [editLabel, setEditLabel] = useState(''); |
| | const [editColor, setEditColor] = useState('#05998c'); |
| | |
| | |
| | const [_annotationIdentified, setAnnotationIdentified] = useState<Record<string, boolean>>({}); |
| | const [annotationAccepted, setAnnotationAccepted] = useState<Record<string, boolean>>({}); |
| | |
| | const labelOptions = [ |
| | 'Cervix', |
| | 'SCJ', |
| | 'OS', |
| | 'TZ', |
| | 'Blood Discharge', |
| | 'Scarring', |
| | 'Growth', |
| | 'Ulcer', |
| | 'Inflammation', |
| | 'Polypoidal Growth', |
| | 'Cauliflower-like Growth', |
| | 'Fungating Mass' |
| | ]; |
| |
|
| | |
| | const filteredLabels = labelOptions.filter(label => |
| | label.toLowerCase().includes(labelInput.toLowerCase()) |
| | ); |
| |
|
| | |
| | useImperativeHandle(ref, () => ({ |
| | addAIAnnotations: (aiAnnotations: Annotation[]) => { |
| | setAnnotations(aiAnnotations); |
| | }, |
| | setImageIndex: (index: number) => { |
| | setSelectedImageIndex(index); |
| | }, |
| | clearAIAnnotations: () => { |
| | setAnnotations(prev => prev.filter(ann => ann.source !== 'ai')); |
| | }, |
| | clearAllAnnotations: () => { |
| | setAnnotations([]); |
| | setPolygonPoints([]); |
| | setCurrentAnnotation(null); |
| | }, |
| | resetViewport: () => { |
| | setSelectedImageIndex(0); |
| | setAnnotations([]); |
| | setPolygonPoints([]); |
| | setCurrentAnnotation(null); |
| | }, |
| | waitForImageReady: () => { |
| | return new Promise<void>((resolve) => { |
| | if (imageLoaded) { |
| | resolve(); |
| | } else { |
| | imageReadyResolveRef.current = resolve; |
| | } |
| | }); |
| | } |
| | })); |
| |
|
| | const images = imageUrls || (imageUrl ? [imageUrl] : []); |
| | const currentImageUrl = images[selectedImageIndex]; |
| |
|
| | |
| | useEffect(() => { |
| | console.log('🖼️ Image switched, clearing annotations'); |
| | setAnnotations([]); |
| | setPolygonPoints([]); |
| | setCurrentAnnotation(null); |
| | setImageLoaded(false); |
| | }, [selectedImageIndex]); |
| |
|
| | useEffect(() => { |
| | const img = new Image(); |
| | img.src = currentImageUrl; |
| | img.onload = () => { |
| | console.log('✅ Image loaded:', { width: img.width, height: img.height }); |
| | setImageDimensions({ width: img.width, height: img.height }); |
| | setImageLoaded(true); |
| | |
| | if (imageReadyResolveRef.current) { |
| | console.log('🔔 Image ready resolver called'); |
| | imageReadyResolveRef.current(); |
| | imageReadyResolveRef.current = null; |
| | } |
| | |
| | setTimeout(() => drawCanvas(), 0); |
| | }; |
| | }, [currentImageUrl]); |
| |
|
| | useEffect(() => { |
| | if (imageLoaded) drawCanvas(); |
| | }, [annotations, currentAnnotation, polygonPoints, imageLoaded]); |
| |
|
| | useEffect(() => { |
| | if (onAnnotationsChange) onAnnotationsChange(annotations); |
| | }, [annotations, onAnnotationsChange]); |
| |
|
| | |
| | useEffect(() => { |
| | return () => { |
| | if (pendingDrawAnimationRef.current) { |
| | cancelAnimationFrame(pendingDrawAnimationRef.current); |
| | } |
| | }; |
| | }, []); |
| |
|
| | const clearAnnotations = () => setAnnotations([]); |
| | const deleteLastAnnotation = () => setAnnotations(prev => prev.slice(0, -1)); |
| |
|
| | const getCanvasCoordinates = (e: React.MouseEvent<HTMLCanvasElement>) => { |
| | const canvas = canvasRef.current; |
| | if (!canvas) return { x: 0, y: 0 }; |
| | const rect = canvas.getBoundingClientRect(); |
| | const scaleX = imageDimensions.width / canvas.width || 1; |
| | const scaleY = imageDimensions.height / canvas.height || 1; |
| | return { x: (e.clientX - rect.left) * scaleX, y: (e.clientY - rect.top) * scaleY }; |
| | }; |
| |
|
| | const startDrawing = (point: Point) => { |
| | setStartPoint(point); |
| | setIsDrawing(true); |
| | }; |
| |
|
| | const finishDrawingRectOrCircle = () => { |
| | if (currentAnnotation && (currentAnnotation.width > 5 || currentAnnotation.height > 5)) { |
| | const ann: Annotation = { ...currentAnnotation, id: Date.now().toString(), label: labelInput, source: 'manual', identified: true }; |
| | setAnnotations(prev => [...prev, ann]); |
| | } |
| | setIsDrawing(false); |
| | setStartPoint(null); |
| | setCurrentAnnotation(null); |
| | }; |
| |
|
| | const handleMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => { |
| | const p = getCanvasCoordinates(e); |
| | if (tool === 'polygon') { |
| | |
| | setPolygonPoints(prev => [...prev, p]); |
| | return; |
| | } |
| | startDrawing(p); |
| | }; |
| |
|
| | const handleMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => { |
| | if (tool === 'polygon') return; |
| | if (!isDrawing || !startPoint) return; |
| | const p = getCanvasCoordinates(e); |
| | const w = p.x - startPoint.x; |
| | const h = p.y - startPoint.y; |
| | const ann: Annotation = { |
| | id: 'temp', |
| | type: tool, |
| | x: w > 0 ? startPoint.x : p.x, |
| | y: h > 0 ? startPoint.y : p.y, |
| | width: Math.abs(w), |
| | height: Math.abs(h), |
| | color, |
| | label: labelInput |
| | }; |
| | setCurrentAnnotation(ann); |
| | }; |
| |
|
| | const handleMouseUp = () => { |
| | if (tool === 'polygon') return; |
| | if (isDrawing) finishDrawingRectOrCircle(); |
| | }; |
| |
|
| | const finishPolygon = () => { |
| | if (polygonPoints.length < 3) return; |
| | const bounds = getBoundsFromPoints(polygonPoints); |
| | const ann: Annotation = { |
| | id: Date.now().toString(), |
| | type: 'polygon', |
| | x: bounds.x, |
| | y: bounds.y, |
| | width: bounds.width, |
| | height: bounds.height, |
| | color, |
| | label: labelInput, |
| | points: polygonPoints, |
| | source: 'manual', |
| | identified: true |
| | }; |
| | setAnnotations(prev => [...prev, ann]); |
| | setPolygonPoints([]); |
| | setLabelInput(''); |
| | }; |
| |
|
| | const cancelPolygon = () => setPolygonPoints([]); |
| |
|
| | const getBoundsFromPoints = (pts: Point[]) => { |
| | const xs = pts.map(p => p.x); |
| | const ys = pts.map(p => p.y); |
| | const minX = Math.min(...xs); |
| | const minY = Math.min(...ys); |
| | const maxX = Math.max(...xs); |
| | const maxY = Math.max(...ys); |
| | return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; |
| | }; |
| |
|
| | const drawCanvas = () => { |
| | const canvas = canvasRef.current; |
| | const container = containerRef.current; |
| | if (!canvas || !container) return; |
| | |
| | |
| | if (pendingDrawAnimationRef.current) { |
| | cancelAnimationFrame(pendingDrawAnimationRef.current); |
| | } |
| | |
| | |
| | pendingDrawAnimationRef.current = requestAnimationFrame(() => { |
| | performDraw(); |
| | }); |
| | }; |
| | |
| | const performDraw = () => { |
| | const canvas = canvasRef.current; |
| | const container = containerRef.current; |
| | if (!canvas || !container) return; |
| | |
| | const ctx = canvas.getContext('2d'); |
| | if (!ctx) return; |
| | |
| | |
| | const stateKey = `${selectedImageIndex}-${annotations.length}-${polygonPoints.length}-${currentAnnotation?.id}`; |
| | if (lastDrawStateRef.current === stateKey && canvas.width > 0) { |
| | return; |
| | } |
| | lastDrawStateRef.current = stateKey; |
| | |
| | |
| | let img = cachedImagesRef.current[currentImageUrl]; |
| | if (!img) { |
| | img = new Image(); |
| | img.src = currentImageUrl; |
| | cachedImagesRef.current[currentImageUrl] = img; |
| | } |
| | |
| | |
| | if (!img.complete) { |
| | img.onload = () => performDraw(); |
| | return; |
| | } |
| | |
| | |
| | const containerWidth = container.clientWidth; |
| | const aspectRatio = img.height / img.width || 1; |
| | const canvasHeight = containerWidth * aspectRatio; |
| | |
| | if (canvas.width !== containerWidth || canvas.height !== canvasHeight) { |
| | canvas.width = containerWidth; |
| | canvas.height = canvasHeight; |
| | } |
| | |
| | |
| | ctx.clearRect(0, 0, canvas.width, canvas.height); |
| | ctx.drawImage(img, 0, 0, canvas.width, canvas.height); |
| |
|
| | |
| | annotations.forEach(a => drawAnnotation(ctx, a, canvas.width, canvas.height)); |
| |
|
| | |
| | if (polygonPoints.length > 0) { |
| | drawPreviewPolygon(ctx, polygonPoints, canvas.width, canvas.height, color); |
| | } |
| |
|
| | |
| | if (currentAnnotation) drawAnnotation(ctx, currentAnnotation, canvas.width, canvas.height); |
| | |
| | pendingDrawAnimationRef.current = null; |
| | }; |
| |
|
| | const drawPreviewPolygon = (ctx: CanvasRenderingContext2D, pts: Point[], canvasWidth: number, canvasHeight: number, col: string) => { |
| | const scaleX = canvasWidth / imageDimensions.width || 1; |
| | const scaleY = canvasHeight / imageDimensions.height || 1; |
| | ctx.strokeStyle = col; |
| | ctx.lineWidth = 2; |
| | ctx.setLineDash([6, 4]); |
| | ctx.beginPath(); |
| | pts.forEach((p, i) => { |
| | const x = p.x * scaleX; |
| | const y = p.y * scaleY; |
| | if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); |
| | }); |
| | ctx.stroke(); |
| | ctx.setLineDash([]); |
| | }; |
| |
|
| | const drawAnnotation = (ctx: CanvasRenderingContext2D, annotation: Annotation, canvasWidth: number, canvasHeight: number) => { |
| | const scaleX = canvasWidth / imageDimensions.width || 1; |
| | const scaleY = canvasHeight / imageDimensions.height || 1; |
| | ctx.strokeStyle = annotation.color; |
| | ctx.lineWidth = 3; |
| | ctx.setLineDash([]); |
| | if (annotation.type === 'rect') { |
| | ctx.strokeRect(annotation.x * scaleX, annotation.y * scaleY, annotation.width * scaleX, annotation.height * scaleY); |
| | |
| | if (annotation.source !== 'ai') { |
| | ctx.fillStyle = annotation.color + '20'; |
| | ctx.fillRect(annotation.x * scaleX, annotation.y * scaleY, annotation.width * scaleX, annotation.height * scaleY); |
| | } |
| | } else if (annotation.type === 'circle') { |
| | const cx = (annotation.x + annotation.width / 2) * scaleX; |
| | const cy = (annotation.y + annotation.height / 2) * scaleY; |
| | const r = Math.max(annotation.width * scaleX, annotation.height * scaleY) / 2; |
| | ctx.beginPath(); |
| | ctx.arc(cx, cy, r, 0, Math.PI * 2); |
| | ctx.stroke(); |
| | |
| | if (annotation.source !== 'ai') { |
| | ctx.fillStyle = annotation.color + '20'; |
| | ctx.fill(); |
| | } |
| | } else if (annotation.type === 'polygon' && annotation.points && Array.isArray(annotation.points) && annotation.points.length > 0) { |
| | ctx.beginPath(); |
| | annotation.points.forEach((p, i) => { |
| | if (p && typeof p.x === 'number' && typeof p.y === 'number') { |
| | const x = p.x * scaleX; |
| | const y = p.y * scaleY; |
| | if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); |
| | } |
| | }); |
| | ctx.closePath(); |
| | ctx.stroke(); |
| | |
| | if (annotation.source !== 'ai') { |
| | ctx.fillStyle = annotation.color + '20'; |
| | ctx.fill(); |
| | } |
| | } |
| | }; |
| |
|
| | |
| | const startEdit = (ann: Annotation) => { |
| | setEditingId(ann.id); |
| | setEditLabel(ann.label || ''); |
| | setEditColor(ann.color || '#05998c'); |
| | }; |
| | const saveEdit = () => { |
| | setAnnotations(prev => prev.map(a => a.id === editingId ? { ...a, label: editLabel, color: editColor } : a)); |
| | setEditingId(null); |
| | }; |
| | const deleteAnnotation = (id: string) => setAnnotations(prev => prev.filter(a => a.id !== id)); |
| |
|
| | const handleAIAssistToggle = async () => { |
| | if (isAILoading) return; |
| | if (isAIAssistEnabled) { |
| | setIsAIAssistEnabled(false); |
| | return; |
| | } |
| | setIsAIAssistEnabled(true); |
| | if (onAIAssist) { |
| | setInternalAILoading(true); |
| | try { |
| | await onAIAssist(); |
| | } catch (error) { |
| | console.error('AI Assist error:', error); |
| | } finally { |
| | setInternalAILoading(false); |
| | } |
| | } |
| | }; |
| |
|
| | const getShapeTypeName = (type: ShapeType): string => { |
| | const typeMap: Record<ShapeType, string> = { |
| | 'rect': 'Rectangle', |
| | 'circle': 'Circle', |
| | 'polygon': 'Polygon' |
| | }; |
| | return typeMap[type] || type; |
| | }; |
| |
|
| | return ( |
| | <div className="space-y-3 md:space-y-4"> |
| | {/* Image Selection - Show if multiple images */} |
| | {images.length > 1 && ( |
| | <div> |
| | <label className="block text-xs font-semibold text-gray-500 uppercase mb-2 md:mb-3"> |
| | Select Image |
| | </label> |
| | <div className="flex gap-2 overflow-x-auto pb-2"> |
| | {images.map((imgUrl, idx) => ( |
| | <button |
| | key={idx} |
| | onClick={() => setSelectedImageIndex(idx)} |
| | className={`relative flex-shrink-0 w-16 h-16 md:w-20 md:h-20 rounded-lg overflow-hidden border-2 transition-all ${ |
| | selectedImageIndex === idx |
| | ? 'border-[#05998c] ring-2 ring-[#05998c]/50' |
| | : 'border-gray-300' |
| | }`} |
| | > |
| | <img |
| | src={imgUrl} |
| | alt={`Image ${idx + 1}`} |
| | className="w-full h-full object-cover" |
| | /> |
| | {/* Grey overlay for selected image */} |
| | {selectedImageIndex === idx && ( |
| | <div className="absolute inset-0 bg-black/30 flex items-center justify-center"> |
| | <div className="w-5 h-5 rounded-full bg-[#05998c] flex items-center justify-center"> |
| | <div className="w-2 h-2 bg-white rounded-full" /> |
| | </div> |
| | </div> |
| | )} |
| | </button> |
| | ))} |
| | </div> |
| | </div> |
| | )} |
| | |
| | <div className="flex flex-col md:flex-row md:items-center md:justify-between gap-2 md:gap-3"> |
| | <div className="flex items-center gap-2"> |
| | <div className="flex items-center gap-2 text-xs md:text-sm text-gray-600 bg-blue-50 px-3 py-1.5 rounded-lg border border-blue-100 whitespace-nowrap"> |
| | <Wrench className="w-4 h-4 text-[#05998c]" /> |
| | <span className="font-medium">Tools</span> |
| | </div> |
| | <div className="flex items-center gap-1 md:gap-2"> |
| | <button onClick={() => setTool('rect')} className={`px-2 md:px-3 py-1 rounded text-xs md:text-sm ${tool === 'rect' ? 'bg-gray-800 text-white' : 'bg-white text-gray-700'} border`}><SquareIcon className="inline w-4 h-4" /></button> |
| | <button onClick={() => setTool('circle')} className={`px-2 md:px-3 py-1 rounded text-xs md:text-sm ${tool === 'circle' ? 'bg-gray-800 text-white' : 'bg-white text-gray-700'} border`}><CircleIcon className="inline w-4 h-4" /></button> |
| | <button onClick={() => setTool('polygon')} className={`px-2 md:px-3 py-1 rounded text-xs md:text-sm ${tool === 'polygon' ? 'bg-gray-800 text-white' : 'bg-white text-gray-700'} border`}><Hexagon className="inline w-4 h-4" /></button> |
| | </div> |
| | </div> |
| | |
| | <div className="flex flex-col gap-2"> |
| | {/* First row: AI Assist on right side */} |
| | <div className="flex justify-end"> |
| | {onAIAssist && ( |
| | <button |
| | onClick={handleAIAssistToggle} |
| | disabled={isAILoading} |
| | className={`px-4 md:px-6 py-2 md:py-3 text-sm md:text-base font-bold text-white rounded-lg disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-lg hover:shadow-xl flex items-center justify-center gap-2 ${ |
| | isAIAssistEnabled |
| | ? 'bg-gradient-to-r from-green-500 to-green-600 border border-green-600 hover:from-green-600 hover:to-green-700' |
| | : 'bg-gradient-to-r from-blue-600 to-blue-700 border border-blue-700 hover:from-blue-700 hover:to-blue-800' |
| | }`} |
| | title={isAIAssistEnabled ? 'AI Assist is ON' : 'Run AI model to automatically detect annotations'} |
| | > |
| | {isAILoading ? ( |
| | <> |
| | <div className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${isAIAssistEnabled ? 'bg-white' : 'bg-gray-300'}`}> |
| | <span className={`inline-block h-4 w-4 transform rounded-full bg-gradient-to-br transition-transform ${isAIAssistEnabled ? 'translate-x-6 from-green-400 to-green-600' : 'translate-x-1 from-blue-400 to-blue-600'}`} /> |
| | </div> |
| | <Loader className="w-5 h-5 animate-spin" /> |
| | <span>Analyzing...</span> |
| | </> |
| | ) : ( |
| | <> |
| | <div className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${isAIAssistEnabled ? 'bg-white' : 'bg-gray-300'}`}> |
| | <span className={`inline-block h-4 w-4 transform rounded-full bg-gradient-to-br transition-transform ${isAIAssistEnabled ? 'translate-x-6 from-green-400 to-green-600' : 'translate-x-1 from-blue-400 to-blue-600'}`} /> |
| | </div> |
| | <Sparkles className="h-5 w-5" /> |
| | <span>{isAIAssistEnabled ? 'AI Assist On' : 'AI Assist'}</span> |
| | </> |
| | )} |
| | </button> |
| | )} |
| | </div> |
| | |
| | {/* Second row: Label, Color, Undo on left, Clear All on right */} |
| | <div className="flex flex-col md:flex-row md:items-center gap-2"> |
| | <div className="relative"> |
| | <input |
| | aria-label="Annotation label" |
| | placeholder="Search or select label" |
| | value={labelInput} |
| | onChange={e => setLabelInput(e.target.value)} |
| | onFocus={() => setIsLabelDropdownOpen(true)} |
| | onBlur={() => setTimeout(() => setIsLabelDropdownOpen(false), 200)} |
| | className="px-3 py-1 border rounded text-xs md:text-sm w-48" |
| | /> |
| | {isLabelDropdownOpen && filteredLabels.length > 0 && ( |
| | <div className="absolute top-full left-0 mt-1 w-48 bg-white border border-gray-300 rounded-lg shadow-lg max-h-60 overflow-y-auto z-50"> |
| | {filteredLabels.map((label, idx) => ( |
| | <button |
| | key={idx} |
| | type="button" |
| | onMouseDown={(e) => { |
| | e.preventDefault(); |
| | setLabelInput(label); |
| | setIsLabelDropdownOpen(false); |
| | }} |
| | className="w-full text-left px-3 py-2 text-xs md:text-sm hover:bg-gray-100 transition-colors" |
| | > |
| | {label} |
| | </button> |
| | ))} |
| | </div> |
| | )} |
| | </div> |
| | <input aria-label="Annotation color" type="color" value={color} onChange={e => setColor(e.target.value)} className="w-10 h-8 p-0 border rounded" /> |
| | <button onClick={deleteLastAnnotation} disabled={annotations.length === 0} className="px-3 md:px-4 py-1 md:py-2 text-xs md:text-sm font-medium text-gray-600 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center justify-center gap-1 md:gap-2"> |
| | <Trash2 className="w-4 h-4" /> |
| | <span className="hidden md:inline">Undo</span> |
| | <span className="inline md:hidden">Undo</span> |
| | </button> |
| | <div className="flex-1"></div> |
| | <button onClick={clearAnnotations} disabled={annotations.length === 0} className="px-3 md:px-4 py-1 md:py-2 text-xs md:text-sm font-medium text-red-600 bg-white border border-red-200 rounded-lg hover:bg-red-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">Clear All</button> |
| | </div> |
| | </div> |
| | </div> |
| |
|
| | <div ref={containerRef} className="relative bg-gray-900 rounded-xl overflow-hidden shadow-2xl border-2 border-gray-700"> |
| | <canvas ref={canvasRef} onMouseDown={handleMouseDown} onMouseMove={handleMouseMove} onMouseUp={handleMouseUp} onMouseLeave={handleMouseUp} className="w-full cursor-crosshair" /> |
| | {/* show guide */} |
| | {annotations.length === 0 && polygonPoints.length === 0 && !isDrawing && ( |
| | <div className="absolute inset-0 flex items-center justify-center pointer-events-none text-white/70 text-xs md:text-sm px-4"> |
| | |
| | </div> |
| | )} |
| | </div> |
| |
|
| | {tool === 'polygon' && ( |
| | <div className="flex flex-col md:flex-row md:items-center gap-2"> |
| | <span className="text-xs md:text-sm text-gray-600">Polygon points: {polygonPoints.length}</span> |
| | <button onClick={finishPolygon} disabled={polygonPoints.length < 3} className="px-3 py-1 text-xs md:text-sm bg-green-600 text-white rounded disabled:opacity-50">Finish Polygon</button> |
| | <button onClick={cancelPolygon} disabled={polygonPoints.length === 0} className="px-3 py-1 text-xs md:text-sm bg-gray-200 rounded">Cancel</button> |
| | </div> |
| | )} |
| |
|
| | {} |
| | <div className="border border-gray-200 rounded-lg overflow-hidden"> |
| | <button |
| | onClick={() => setIsAnnotationsOpen(!isAnnotationsOpen)} |
| | className="w-full flex items-center justify-between p-3 md:p-4 bg-gray-50 hover:bg-gray-100 transition-colors" |
| | > |
| | <div className="flex items-center gap-2"> |
| | <span className="text-xs md:text-sm font-semibold text-gray-700"> |
| | Annotations ({annotations.length}) |
| | </span> |
| | </div> |
| | {isAnnotationsOpen ? ( |
| | <ChevronUp className="w-4 h-4 text-gray-600" /> |
| | ) : ( |
| | <ChevronDown className="w-4 h-4 text-gray-600" /> |
| | )} |
| | </button> |
| |
|
| | {isAnnotationsOpen && ( |
| | <div className="overflow-x-auto border-t border-gray-200 max-h-96 overflow-y-auto"> |
| | {annotations.length === 0 ? ( |
| | <div className="p-3 md:p-4 bg-white text-center"> |
| | <p className="text-xs text-gray-500">No annotations yet. Draw on the image to create annotations.</p> |
| | </div> |
| | ) : ( |
| | <table className="w-full"> |
| | <thead className="bg-gray-50 sticky top-0 border-b border-gray-200"> |
| | <tr> |
| | <th className="px-3 md:px-4 py-2 text-left text-xs font-semibold text-gray-700">Annotation</th> |
| | <th className="px-3 md:px-4 py-2 text-center text-xs font-semibold text-gray-700">Identified</th> |
| | <th className="px-3 md:px-4 py-2 text-center text-xs font-semibold text-gray-700">Source</th> |
| | <th className="px-3 md:px-4 py-2 text-center text-xs font-semibold text-gray-700">Action</th> |
| | </tr> |
| | </thead> |
| | <tbody> |
| | {annotations.map((a) => ( |
| | <tr key={a.id} className="border-b border-gray-100 hover:bg-gray-50 transition-colors"> |
| | {/* Annotation Column - shown in color it was drawn */} |
| | <td className="px-3 md:px-4 py-3"> |
| | {editingId === a.id ? ( |
| | <div className="flex items-center gap-2"> |
| | <input |
| | type="color" |
| | value={editColor} |
| | onChange={e => setEditColor(e.target.value)} |
| | className="w-6 h-6 rounded p-0 border cursor-pointer" |
| | /> |
| | <input |
| | value={editLabel} |
| | onChange={e => setEditLabel(e.target.value)} |
| | className="px-2 py-1 border rounded text-xs flex-1" |
| | placeholder="Label" |
| | /> |
| | </div> |
| | ) : ( |
| | <div className="flex items-center gap-3"> |
| | <div className="w-6 h-6 rounded-sm flex-shrink-0" style={{ backgroundColor: a.color }} /> |
| | <div> |
| | <div className="text-sm font-medium text-gray-900">{a.label || '(no label)'}</div> |
| | <div className="text-xs text-gray-500">{getShapeTypeName(a.type)}</div> |
| | </div> |
| | </div> |
| | )} |
| | </td> |
| | |
| | {/* Identified Checkbox Column */} |
| | <td className="px-3 md:px-4 py-3 text-center"> |
| | <input |
| | type="checkbox" |
| | checked={a.identified || false} |
| | onChange={(e) => { |
| | setAnnotations(prev => prev.map(ann => |
| | ann.id === a.id ? { ...ann, identified: e.target.checked } : ann |
| | )); |
| | }} |
| | className="w-4 h-4 rounded border-gray-300 cursor-pointer" |
| | /> |
| | </td> |
| | |
| | {/* Source Column (Manual/AI) */} |
| | <td className="px-3 md:px-4 py-3 text-center"> |
| | <span className={`inline-block px-2 py-1 rounded text-xs font-medium ${ |
| | a.source === 'ai' |
| | ? 'bg-blue-100 text-blue-800' |
| | : 'bg-gray-100 text-gray-800' |
| | }`}> |
| | {a.source === 'ai' ? 'AI' : 'Manual'} |
| | </span> |
| | </td> |
| | |
| | {/* Action Column - Accept/Reject for AI, Edit/Delete for Manual */} |
| | <td className="px-3 md:px-4 py-3"> |
| | <div className="flex items-center justify-center gap-2"> |
| | {editingId === a.id ? ( |
| | <> |
| | <button |
| | onClick={saveEdit} |
| | className="px-2 py-1 text-xs font-medium bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors" |
| | > |
| | Save |
| | </button> |
| | <button |
| | onClick={() => setEditingId(null)} |
| | className="px-2 py-1 text-xs font-medium bg-gray-300 text-gray-800 rounded hover:bg-gray-400 transition-colors" |
| | > |
| | Cancel |
| | </button> |
| | </> |
| | ) : a.source === 'ai' ? ( |
| | <> |
| | {annotationAccepted[a.id] === true ? ( |
| | <div className="flex items-center gap-2"> |
| | <span className="px-3 py-1 text-xs font-medium bg-green-100 text-green-700 rounded-full"> |
| | ✓ Accepted |
| | </span> |
| | <button |
| | onClick={() => { |
| | setAnnotationAccepted(prev => { |
| | const next = { ...prev }; |
| | delete next[a.id]; |
| | return next; |
| | }); |
| | setAnnotationIdentified(prev => { |
| | const next = { ...prev }; |
| | delete next[a.id]; |
| | return next; |
| | }); |
| | }} |
| | className="px-2 py-1 text-[11px] font-medium bg-white border border-gray-200 text-gray-700 rounded hover:bg-gray-50 transition-colors" |
| | > |
| | Undo |
| | </button> |
| | </div> |
| | ) : annotationAccepted[a.id] === false ? ( |
| | <span className="px-3 py-1 text-xs font-medium bg-red-100 text-red-700 rounded-full"> |
| | ✕ Rejected |
| | </span> |
| | ) : ( |
| | <> |
| | <button |
| | onClick={() => { |
| | setAnnotations(prev => prev.map(ann => |
| | ann.id === a.id ? { ...ann, identified: true } : ann |
| | )); |
| | setAnnotationAccepted(prev => ({ |
| | ...prev, |
| | [a.id]: true |
| | })); |
| | setAnnotationIdentified(prev => ({ |
| | ...prev, |
| | [a.id]: true |
| | })); |
| | }} |
| | className="px-2 py-1 text-xs font-medium bg-green-50 text-green-700 border border-green-200 rounded hover:bg-green-100 transition-colors" |
| | > |
| | ✓ Accept |
| | </button> |
| | <button |
| | onClick={() => { |
| | setAnnotations(prev => prev.filter(item => item.id !== a.id)); |
| | setAnnotationAccepted(prev => { |
| | const next = { ...prev }; |
| | delete next[a.id]; |
| | return next; |
| | }); |
| | setAnnotationIdentified(prev => { |
| | const next = { ...prev }; |
| | delete next[a.id]; |
| | return next; |
| | }); |
| | }} |
| | className="px-2 py-1 text-xs font-medium bg-red-50 text-red-700 border border-red-200 rounded hover:bg-red-100 transition-colors" |
| | > |
| | ✕ Reject |
| | </button> |
| | </> |
| | )} |
| | </> |
| | ) : ( |
| | <> |
| | <button |
| | onClick={() => startEdit(a)} |
| | className="px-2 py-1 text-xs font-medium bg-white border border-gray-300 rounded hover:bg-gray-50 transition-colors" |
| | > |
| | Edit |
| | </button> |
| | <button |
| | onClick={() => deleteAnnotation(a.id)} |
| | className="px-2 py-1 text-xs font-medium bg-red-50 text-red-700 border border-red-200 rounded hover:bg-red-100 transition-colors" |
| | > |
| | Delete |
| | </button> |
| | </> |
| | )} |
| | </div> |
| | </td> |
| | </tr> |
| | ))} |
| | </tbody> |
| | </table> |
| | )} |
| | </div> |
| | )} |
| | </div> |
| | </div> |
| | ); |
| | }); |
| |
|
| | ImageAnnotatorComponent.displayName = 'ImageAnnotator'; |
| |
|
| | export const ImageAnnotator = ImageAnnotatorComponent; |