import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { XIcon } from './icons'; export type AnnotatorTool = 'pen' | 'arrow' | 'rect' | 'circle' | 'text' | 'highlighter'; export type AnnotatorColor = '#ef4444' | '#3b82f6' | '#22c55e' | '#facc15' | '#a855f7' | '#ffffff'; export type AnnotatorWidth = 2 | 4 | 8; export interface AnnotatorOp { tool: AnnotatorTool; color: AnnotatorColor; width: AnnotatorWidth; points?: Array<{ x: number; y: number }>; from?: { x: number; y: number }; to?: { x: number; y: number }; text?: string; at?: { x: number; y: number }; } interface Props { file: File; onCancel: () => void; onSave: (next: File) => void; } export function ImageAnnotator({ file, onCancel, onSave }: Props) { const { t } = useTranslation(); const canvasRef = useRef(null); const imageRef = useRef(null); const [imageLoaded, setImageLoaded] = useState(false); const [ops, setOps] = useState([]); const [redoStack, setRedoStack] = useState([]); // tool/color/width are read by Task 2 (drawing engine) and Task 3 (toolbar). // Defaults shown here are intentional so T2's pointer handlers work // immediately with sensible behavior before T3's UI is wired. const [tool, setTool] = useState('pen'); const [color, setColor] = useState('#ef4444'); const [width, setWidth] = useState(4); const draftRef = useRef(null); const [draftTick, setDraftTick] = useState(0); // Hold a stable ref to onCancel so the image-load effect doesn't depend // on its identity. Without this, parents that pass an inline `() => …` // re-render the modal on every keystroke / state change, the effect re- // runs, the previous URL.createObjectURL gets revoked WHILE the new img // is still decoding → img.onerror fires ("file not found") → onCancel → // modal flashes open + closes instantly. const onCancelRef = useRef(onCancel); useEffect(() => { onCancelRef.current = onCancel; }, [onCancel]); useEffect(() => { // React 18 strict mode in dev double-mounts effects to test idempotency. // The first run creates a blob URL, sets img.src, returns a cleanup // that revokes — and the cleanup fires BEFORE the (still-in-flight) // image fetch completes. The browser then emits ERR_FILE_NOT_FOUND for // the revoked URL → img.onerror → modal closes instantly. The // `cancelled` flag guards every callback so a torn-down run can't // close the modal that the second mount just opened. let cancelled = false; const url = URL.createObjectURL(file); const img = new Image(); img.onload = () => { if (cancelled) return; imageRef.current = img; setImageLoaded(true); }; img.onerror = () => { if (cancelled) return; console.error('ImageAnnotator: failed to decode source image'); onCancelRef.current(); }; img.src = url; return () => { cancelled = true; URL.revokeObjectURL(url); }; }, [file]); useEffect(() => { if (!imageLoaded) return; const cv = canvasRef.current; const img = imageRef.current; if (!cv || !img) return; cv.width = img.naturalWidth; cv.height = img.naturalHeight; const ctx = cv.getContext('2d'); if (!ctx) return; ctx.drawImage(img, 0, 0); for (const op of ops) { renderOp(ctx, op); } if (draftRef.current) { renderOp(ctx, draftRef.current); } }, [imageLoaded, ops, draftTick]); useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onCancel(); if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) { e.preventDefault(); handleUndo(); } else if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) { e.preventDefault(); handleRedo(); } }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }); const handleUndo = () => { setOps((cur) => { if (cur.length === 0) return cur; const next = cur.slice(0, -1); setRedoStack((r) => [...r, cur[cur.length - 1]!]); return next; }); }; const handleRedo = () => { setRedoStack((r) => { if (r.length === 0) return r; const top = r[r.length - 1]!; setOps((cur) => [...cur, top]); return r.slice(0, -1); }); }; const handleReset = () => { setOps([]); setRedoStack([]); }; const handleSave = () => { const cv = canvasRef.current; if (!cv) return; cv.toBlob((blob) => { if (!blob) { console.error('ImageAnnotator: toBlob returned null'); return; } const baseName = file.name.replace(/\.[^.]+$/, ''); const next = new File([blob], baseName + '-annotated.png', { type: 'image/png', lastModified: Date.now(), }); onSave(next); }, 'image/png'); }; function canvasPoint(e: React.PointerEvent): { x: number; y: number } { const cv = canvasRef.current!; const rect = cv.getBoundingClientRect(); const scaleX = cv.width / rect.width; const scaleY = cv.height / rect.height; return { x: (e.clientX - rect.left) * scaleX, y: (e.clientY - rect.top) * scaleY, }; } const handlePointerDown = (e: React.PointerEvent) => { if (!imageLoaded) return; const cv = canvasRef.current; if (!cv) return; cv.setPointerCapture(e.pointerId); const p = canvasPoint(e); if (tool === 'text') { const value = window.prompt( t('app:annotator.text_prompt', { defaultValue: 'Text eingeben:' }), '', ); if (value !== null && value.trim().length > 0) { setOps((cur) => [...cur, { tool: 'text', color, width, text: value, at: p }]); setRedoStack([]); } return; } if (tool === 'pen' || tool === 'highlighter') { draftRef.current = { tool, color, width, points: [p] }; } else { draftRef.current = { tool, color, width, from: p, to: p }; } setDraftTick((n) => n + 1); }; const handlePointerMove = (e: React.PointerEvent) => { if (!draftRef.current) return; const p = canvasPoint(e); const cur = draftRef.current; if (cur.tool === 'pen' || cur.tool === 'highlighter') { cur.points = [...(cur.points ?? []), p]; } else { cur.to = p; } setDraftTick((n) => n + 1); }; const handlePointerUp = (e: React.PointerEvent) => { const cv = canvasRef.current; if (cv && cv.hasPointerCapture(e.pointerId)) cv.releasePointerCapture(e.pointerId); const cur = draftRef.current; draftRef.current = null; if (!cur) return; const hasContent = (cur.tool === 'pen' || cur.tool === 'highlighter') ? (cur.points?.length ?? 0) >= 2 : !!(cur.from && cur.to && (cur.from.x !== cur.to.x || cur.from.y !== cur.to.y)); if (hasContent) { setOps((p) => [...p, cur]); setRedoStack([]); } setDraftTick((n) => n + 1); }; return (

{t('app:annotator.title', { defaultValue: 'Bild bearbeiten' })}

{imageLoaded ? ( ) : (

{t('app:annotator.loading', { defaultValue: 'Bild wird geladen…' })}

)}
); } function toolGlyph(t: AnnotatorTool): string { switch (t) { case 'pen': return '✎'; case 'highlighter': return '🖍'; case 'arrow': return '↗'; case 'rect': return '▭'; case 'circle': return '◯'; case 'text': return 'T'; } } function renderOp(ctx: CanvasRenderingContext2D, op: AnnotatorOp): void { ctx.save(); ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.strokeStyle = op.color; ctx.fillStyle = op.color; ctx.lineWidth = op.width; switch (op.tool) { case 'pen': { const pts = op.points; if (!pts || pts.length < 1) break; ctx.beginPath(); ctx.moveTo(pts[0]!.x, pts[0]!.y); for (let i = 1; i < pts.length; i++) { ctx.lineTo(pts[i]!.x, pts[i]!.y); } ctx.stroke(); break; } case 'highlighter': { const pts = op.points; if (!pts || pts.length < 1) break; ctx.globalAlpha = 0.35; ctx.lineWidth = op.width * 4; ctx.beginPath(); ctx.moveTo(pts[0]!.x, pts[0]!.y); for (let i = 1; i < pts.length; i++) { ctx.lineTo(pts[i]!.x, pts[i]!.y); } ctx.stroke(); break; } case 'rect': { const { from, to } = op; if (!from || !to) break; ctx.strokeRect( Math.min(from.x, to.x), Math.min(from.y, to.y), Math.abs(to.x - from.x), Math.abs(to.y - from.y), ); break; } case 'circle': { const { from, to } = op; if (!from || !to) break; const cx = (from.x + to.x) / 2; const cy = (from.y + to.y) / 2; const rx = Math.abs(to.x - from.x) / 2; const ry = Math.abs(to.y - from.y) / 2; ctx.beginPath(); ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2); ctx.stroke(); break; } case 'arrow': { const { from, to } = op; if (!from || !to) break; ctx.beginPath(); ctx.moveTo(from.x, from.y); ctx.lineTo(to.x, to.y); ctx.stroke(); const angle = Math.atan2(to.y - from.y, to.x - from.x); const head = Math.max(12, op.width * 3); ctx.beginPath(); ctx.moveTo(to.x, to.y); ctx.lineTo( to.x - head * Math.cos(angle - Math.PI / 6), to.y - head * Math.sin(angle - Math.PI / 6), ); ctx.moveTo(to.x, to.y); ctx.lineTo( to.x - head * Math.cos(angle + Math.PI / 6), to.y - head * Math.sin(angle + Math.PI / 6), ); ctx.stroke(); break; } case 'text': { const { at, text } = op; if (!at || !text) break; const fontSize = Math.max(14, op.width * 6); ctx.font = '600 ' + fontSize + 'px Inter, system-ui, sans-serif'; ctx.textBaseline = 'top'; ctx.fillText(text, at.x, at.y); break; } } ctx.restore(); }