diff --git a/apps/desktop/src/components/ImageAnnotator.tsx b/apps/desktop/src/components/ImageAnnotator.tsx index fb3a182..c8c505a 100644 --- a/apps/desktop/src/components/ImageAnnotator.tsx +++ b/apps/desktop/src/components/ImageAnnotator.tsx @@ -38,7 +38,10 @@ export function ImageAnnotator({ file, onCancel, onSave }: Props) { const [color, _setColor] = useState('#ef4444'); const [width, _setWidth] = useState(4); - void tool; void color; void width; void _setTool; void _setColor; void _setWidth; void redoStack; + const draftRef = useRef(null); + const [draftTick, setDraftTick] = useState(0); + + void _setTool; void _setColor; void _setWidth; void redoStack; useEffect(() => { const url = URL.createObjectURL(file); @@ -68,7 +71,10 @@ export function ImageAnnotator({ file, onCancel, onSave }: Props) { for (const op of ops) { renderOp(ctx, op); } - }, [imageLoaded, ops]); + if (draftRef.current) { + renderOp(ctx, draftRef.current); + } + }, [imageLoaded, ops, draftTick]); useEffect(() => { const onKey = (e: KeyboardEvent) => { @@ -125,6 +131,73 @@ export function ImageAnnotator({ file, onCancel, onSave }: Props) { }, '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 (
) : (

@@ -188,7 +265,95 @@ export function ImageAnnotator({ file, onCancel, onSave }: Props) { ); } -// Stub — filled in by P4A.T2 with the actual draw switch per tool. -function renderOp(_ctx: CanvasRenderingContext2D, _op: AnnotatorOp): void { - // implemented in P4A.T2 +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(); }