import React, { useState, useCallback, useEffect, useRef } from 'react'; import { Canvas } from './Canvas'; import { Toolbar } from './Toolbar'; import { renderStroke } from './utils'; import type { Point } from './types'; import { useImageAnnotatorShortcuts } from '../../shortcuts'; import { DEFAULT_STROKE_HISTORY_STATE, clearStrokeHistory, recordStroke, redoStroke, undoStroke, type StrokeHistoryState, } from './strokeHistory'; interface ImageAnnotatorProps { imageSrc: string; isOpen: boolean; onAccept: (blob: Blob, hasDrawings: boolean, name: string) => Promise; onClose: () => void; /** Pre-populated image name (derived from filename) */ initialName?: string; } export const ImageAnnotator: React.FC = ({ imageSrc, isOpen, onAccept, onClose, initialName = '', }) => { const [state, setState] = useState(DEFAULT_STROKE_HISTORY_STATE); const [saving, setSaving] = useState(false); const [name, setName] = useState(initialName); const imageRef = useRef(null); const nameInputRef = useRef(null); // Reset state when dialog opens useEffect(() => { if (isOpen) { setState(DEFAULT_STROKE_HISTORY_STATE); setName(initialName); } }, [isOpen, initialName]); const handleStrokeStart = useCallback((point: Point) => { const id = crypto.randomUUID(); setState(s => ({ ...s, currentStroke: { id, tool: s.tool, points: [point], color: s.color, size: s.strokeSize, }, })); }, []); const handleStrokeMove = useCallback((point: Point) => { setState(s => { if (!s.currentStroke) return s; return { ...s, currentStroke: { ...s.currentStroke, points: [...s.currentStroke.points, point], }, }; }); }, []); const handleStrokeEnd = useCallback(() => { setState(s => { if (!s.currentStroke || s.currentStroke.points.length < 2) { return { ...s, currentStroke: null }; } return recordStroke(s, s.currentStroke); }); }, []); const handleUndo = useCallback(() => { setState(undoStroke); }, []); const handleRedo = useCallback(() => { setState(redoStroke); }, []); const handleClear = useCallback(() => { setState(clearStrokeHistory); }, []); const handleImageLoad = useCallback((img: HTMLImageElement) => { imageRef.current = img; }, []); const handleAccept = async () => { if (saving) return; setSaving(true); try { const img = imageRef.current; if (!img) { onClose(); return; } const hasDrawings = state.strokes.length > 0; const finalName = name.trim() || initialName || 'image'; // If no drawings, just pass through original image if (!hasDrawings) { const response = await fetch(imageSrc); const blob = await response.blob(); await onAccept(blob, false, finalName); onClose(); return; } // Composite image + drawings const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); if (!ctx) throw new Error('Canvas 2D context is unavailable'); canvas.width = img.naturalWidth; canvas.height = img.naturalHeight; // Draw original image ctx.drawImage(img, 0, 0); // Scale factor from display size to natural size const scale = img.naturalWidth / img.clientWidth; // Draw all strokes at full resolution state.strokes.forEach(stroke => { renderStroke(ctx, stroke, scale); }); // Convert to blob canvas.toBlob(async (blob) => { if (blob) { await onAccept(blob, true, finalName); } onClose(); }, 'image/png'); } catch (err) { console.error('Failed to save annotated image:', err); onClose(); } finally { setSaving(false); } }; const keyboardTargetIsInput = (event: KeyboardEvent): boolean => event.composedPath()[0] instanceof HTMLInputElement; useImageAnnotatorShortcuts({ handlers: { penTool: { when: (event) => isOpen && !saving && !keyboardTargetIsInput(event), handle: () => setState((current) => ({ ...current, tool: 'pen' })) }, arrowTool: { when: (event) => isOpen && !saving && !keyboardTargetIsInput(event), handle: () => setState((current) => ({ ...current, tool: 'arrow' })) }, circleTool: { when: (event) => isOpen && !saving && !keyboardTargetIsInput(event), handle: () => setState((current) => ({ ...current, tool: 'circle' })) }, undo: { when: (event) => isOpen && !saving && !keyboardTargetIsInput(event) && state.strokes.length > 0, handle: handleUndo, }, redo: { when: (event) => isOpen && !saving && !keyboardTargetIsInput(event) && state.futureStrokes.length > 0, handle: handleRedo, }, save: { when: (event) => isOpen && !saving && !keyboardTargetIsInput(event), handle: () => { void handleAccept(); }, }, }, }); const handleBackdropClick = (e: React.MouseEvent) => { if (e.target === e.currentTarget) { handleAccept(); } }; if (!isOpen) return null; return (
{/* Canvas with image and toolbar */}
e.stopPropagation()} > {/* Toolbar - above image */} 0} canRedo={state.futureStrokes.length > 0} onToolChange={(tool) => setState(s => ({ ...s, tool }))} onColorChange={(color) => setState(s => ({ ...s, color }))} onStrokeSizeChange={(strokeSize) => setState(s => ({ ...s, strokeSize }))} onUndo={handleUndo} onRedo={handleRedo} onClear={handleClear} onSave={handleAccept} /> {/* Image name input */} {initialName && (
setName(e.target.value)} onKeyDown={(e) => { if (e.key === 'Escape') { e.preventDefault(); e.currentTarget.blur(); return; } if (e.key === 'Enter' && !e.nativeEvent.isComposing) { e.preventDefault(); handleAccept(); } }} className="flex-1 px-2 py-1 text-xs bg-muted/50 border border-border rounded-md text-foreground focus:outline-none focus:ring-1 focus:ring-primary" placeholder="Image name..." />
)} {/* Accept hint */}
Press Esc or Enter or click outside to accept
{/* Loading overlay */} {saving && (
Saving...
)}
); }; export default ImageAnnotator;