/** * @ai-summary Post-capture crop tool with draggable handles * @ai-context Allows user to adjust crop area with touch/mouse, confirm or retake */ import React, { useCallback, useState, useRef, useEffect } from 'react'; import { Box, IconButton, Button, Typography, CircularProgress, useMediaQuery } from '@mui/material'; import CheckIcon from '@mui/icons-material/Check'; import RefreshIcon from '@mui/icons-material/Refresh'; import ReplayIcon from '@mui/icons-material/Replay'; import SkipNextIcon from '@mui/icons-material/SkipNext'; import type { CropToolProps } from './types'; import { useImageCrop, type CropHandle } from './useImageCrop'; export const CropTool: React.FC = ({ imageSrc, lockAspectRatio = false, initialCrop, aspectRatio, onConfirm, onReset, onRetake, onSkip, }) => { const [isProcessing, setIsProcessing] = useState(false); const imageAreaRef = useRef(null); const [imageMaxHeight, setImageMaxHeight] = useState(0); // Responsive handle sizing: 44px standard, 32px on compact viewports const isCompactViewport = useMediaQuery('(max-height: 400px)'); const handleConfig = { size: isCompactViewport ? 32 : 44, visual: isCompactViewport ? 12 : 16 }; const { cropArea, cropDrawn, isDragging, resetCrop, executeCrop, handleDragStart, handleDrawStart } = useImageCrop({ aspectRatio: lockAspectRatio ? aspectRatio : undefined, initialCrop, }); const showCropArea = cropDrawn || (isDragging && cropArea.width > 1 && cropArea.height > 1); // Measure available height for the image so the crop container // matches the rendered image exactly (fixes mobile crop offset) useEffect(() => { const updateMaxHeight = () => { if (imageAreaRef.current) { const rect = imageAreaRef.current.getBoundingClientRect(); setImageMaxHeight(rect.height - 32); // subtract p:2 padding (16px * 2) } }; updateMaxHeight(); window.addEventListener('resize', updateMaxHeight); return () => window.removeEventListener('resize', updateMaxHeight); }, []); const handleConfirm = useCallback(async () => { setIsProcessing(true); try { const croppedBlob = await executeCrop(imageSrc); onConfirm(croppedBlob); } catch (error) { console.error('Failed to crop image:', error); // On error, skip cropping and use original onSkip(); } finally { setIsProcessing(false); } }, [executeCrop, imageSrc, onConfirm, onSkip]); const handleReset = useCallback(() => { resetCrop(); onReset(); }, [resetCrop, onReset]); return ( {/* Image with crop overlay */} {/* Source image */} Captured 0 ? `${imageMaxHeight}px` : '70vh', display: 'block', }} draggable={false} /> {/* Draw surface for free-form rectangle drawing */} {!cropDrawn && ( )} {/* Dark overlay outside crop area */} {showCropArea && ( {/* Top overlay */} {/* Bottom overlay */} {/* Left overlay */} {/* Right overlay */} )} {/* Crop area border and handles */} {showCropArea && ( {/* Handles only appear after drawing is complete */} {cropDrawn && ( <> {/* Move handle (center area) */} {/* Corner handles */} {/* Edge handles */} )} {/* Grid lines for alignment */} {Array.from({ length: 9 }).map((_, i) => ( ))} )} {/* Instructions */} {cropDrawn ? 'Drag handles to adjust crop area' : 'Tap and drag to select crop area'} {/* Action buttons */} {isProcessing ? ( ) : ( )} ); }; interface CropHandleProps { handle: CropHandle; onDragStart: (handle: CropHandle, event: React.MouseEvent | React.TouchEvent) => void; config: { size: number; visual: number }; position: | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' | 'top' | 'bottom' | 'left' | 'right'; } const CropHandle: React.FC = ({ handle, onDragStart, position, config }) => { const handleSize = config.size; const handleVisualSize = config.visual; const positionStyles: Record = { 'top-left': { top: -handleSize / 2, left: -handleSize / 2, cursor: 'nwse-resize', }, 'top-right': { top: -handleSize / 2, right: -handleSize / 2, cursor: 'nesw-resize', }, 'bottom-left': { bottom: -handleSize / 2, left: -handleSize / 2, cursor: 'nesw-resize', }, 'bottom-right': { bottom: -handleSize / 2, right: -handleSize / 2, cursor: 'nwse-resize', }, top: { top: -handleSize / 2, left: '50%', transform: 'translateX(-50%)', cursor: 'ns-resize', }, bottom: { bottom: -handleSize / 2, left: '50%', transform: 'translateX(-50%)', cursor: 'ns-resize', }, left: { top: '50%', left: -handleSize / 2, transform: 'translateY(-50%)', cursor: 'ew-resize', }, right: { top: '50%', right: -handleSize / 2, transform: 'translateY(-50%)', cursor: 'ew-resize', }, }; const isCorner = position.includes('-'); return ( onDragStart(handle, e)} onTouchStart={(e) => onDragStart(handle, e)} sx={{ position: 'absolute', width: handleSize, height: handleSize, display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 10, ...positionStyles[position], }} > ); }; interface CropHandleAreaProps { handle: CropHandle; onDragStart: (handle: CropHandle, event: React.MouseEvent | React.TouchEvent) => void; sx?: React.CSSProperties & { [key: string]: unknown }; } const CropHandleArea: React.FC = ({ handle, onDragStart, sx }) => { return ( onDragStart(handle, e)} onTouchStart={(e) => onDragStart(handle, e)} sx={sx} /> ); }; export default CropTool;