Files
motovaultpro/frontend/src/shared/components/CameraCapture/CropTool.tsx
Eric Gullickson 7c8b6fda2a
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 3m14s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 33s
Deploy to Staging / Verify Staging (pull_request) Successful in 2m19s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 8s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
feat: add camera capture component (refs #66)
Implements a reusable React camera capture component with:
- getUserMedia API for camera access on mobile and desktop
- Translucent aspect-ratio guidance overlays (VIN ~6:1, receipt ~2:3)
- Post-capture crop tool with draggable handles
- File input fallback for desktop and unsupported browsers
- Support for HEIC, JPEG, PNG (sent as-is to server)
- Full mobile responsiveness (320px - 1920px)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 15:05:18 -06:00

397 lines
11 KiB
TypeScript

/**
* @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 } from 'react';
import { Box, IconButton, Button, Typography, CircularProgress } 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<CropToolProps> = ({
imageSrc,
lockAspectRatio = false,
aspectRatio,
onConfirm,
onReset,
onRetake,
onSkip,
}) => {
const [isProcessing, setIsProcessing] = useState(false);
const { cropArea, isDragging, resetCrop, executeCrop, handleDragStart } =
useImageCrop({
aspectRatio: lockAspectRatio ? aspectRatio : undefined,
});
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 (
<Box
sx={{
position: 'relative',
width: '100%',
height: '100%',
backgroundColor: 'black',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}
>
{/* Image with crop overlay */}
<Box
sx={{
flex: 1,
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
p: 2,
}}
>
<Box
data-crop-container
sx={{
position: 'relative',
maxWidth: '100%',
maxHeight: '100%',
userSelect: 'none',
touchAction: isDragging ? 'none' : 'auto',
}}
>
{/* Source image */}
<img
src={imageSrc}
alt="Captured"
style={{
maxWidth: '100%',
maxHeight: '100%',
display: 'block',
}}
draggable={false}
/>
{/* Dark overlay outside crop area */}
<Box
sx={{
position: 'absolute',
inset: 0,
pointerEvents: 'none',
}}
>
{/* Top overlay */}
<Box
sx={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: `${cropArea.y}%`,
backgroundColor: 'rgba(0, 0, 0, 0.6)',
}}
/>
{/* Bottom overlay */}
<Box
sx={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: `${100 - cropArea.y - cropArea.height}%`,
backgroundColor: 'rgba(0, 0, 0, 0.6)',
}}
/>
{/* Left overlay */}
<Box
sx={{
position: 'absolute',
top: `${cropArea.y}%`,
left: 0,
width: `${cropArea.x}%`,
height: `${cropArea.height}%`,
backgroundColor: 'rgba(0, 0, 0, 0.6)',
}}
/>
{/* Right overlay */}
<Box
sx={{
position: 'absolute',
top: `${cropArea.y}%`,
right: 0,
width: `${100 - cropArea.x - cropArea.width}%`,
height: `${cropArea.height}%`,
backgroundColor: 'rgba(0, 0, 0, 0.6)',
}}
/>
</Box>
{/* Crop area with handles */}
<Box
sx={{
position: 'absolute',
top: `${cropArea.y}%`,
left: `${cropArea.x}%`,
width: `${cropArea.width}%`,
height: `${cropArea.height}%`,
border: '2px solid white',
boxSizing: 'border-box',
}}
>
{/* Move handle (center area) */}
<CropHandleArea
handle="move"
onDragStart={handleDragStart}
sx={{
position: 'absolute',
inset: 8,
cursor: 'move',
}}
/>
{/* Corner handles */}
<CropHandle handle="nw" onDragStart={handleDragStart} position="top-left" />
<CropHandle handle="ne" onDragStart={handleDragStart} position="top-right" />
<CropHandle handle="sw" onDragStart={handleDragStart} position="bottom-left" />
<CropHandle handle="se" onDragStart={handleDragStart} position="bottom-right" />
{/* Edge handles */}
<CropHandle handle="n" onDragStart={handleDragStart} position="top" />
<CropHandle handle="s" onDragStart={handleDragStart} position="bottom" />
<CropHandle handle="w" onDragStart={handleDragStart} position="left" />
<CropHandle handle="e" onDragStart={handleDragStart} position="right" />
{/* Grid lines for alignment */}
<Box
sx={{
position: 'absolute',
inset: 0,
display: 'grid',
gridTemplateColumns: '1fr 1fr 1fr',
gridTemplateRows: '1fr 1fr 1fr',
pointerEvents: 'none',
opacity: isDragging ? 1 : 0.5,
transition: 'opacity 0.2s',
}}
>
{Array.from({ length: 9 }).map((_, i) => (
<Box
key={i}
sx={{
borderRight: i % 3 !== 2 ? '1px solid rgba(255,255,255,0.3)' : 'none',
borderBottom: i < 6 ? '1px solid rgba(255,255,255,0.3)' : 'none',
}}
/>
))}
</Box>
</Box>
</Box>
</Box>
{/* Instructions */}
<Box sx={{ px: 2, py: 1, textAlign: 'center' }}>
<Typography variant="body2" sx={{ color: 'rgba(255, 255, 255, 0.7)' }}>
Drag to adjust crop area
</Typography>
</Box>
{/* Action buttons */}
<Box
sx={{
display: 'flex',
justifyContent: 'space-around',
alignItems: 'center',
p: 2,
gap: 2,
background: 'linear-gradient(to top, rgba(0,0,0,0.8) 0%, rgba(0,0,0,0.5) 100%)',
}}
>
<Button
onClick={onRetake}
startIcon={<ReplayIcon />}
sx={{ color: 'white' }}
disabled={isProcessing}
>
Retake
</Button>
<Button
onClick={handleReset}
startIcon={<RefreshIcon />}
sx={{ color: 'white' }}
disabled={isProcessing}
>
Reset
</Button>
<Button
onClick={onSkip}
startIcon={<SkipNextIcon />}
sx={{ color: 'white' }}
disabled={isProcessing}
>
Skip
</Button>
<IconButton
onClick={handleConfirm}
disabled={isProcessing}
aria-label="Confirm crop"
sx={{
width: 56,
height: 56,
backgroundColor: 'primary.main',
color: 'white',
'&:hover': {
backgroundColor: 'primary.dark',
},
'&:disabled': {
backgroundColor: 'action.disabled',
},
}}
>
{isProcessing ? (
<CircularProgress size={24} color="inherit" />
) : (
<CheckIcon />
)}
</IconButton>
</Box>
</Box>
);
};
interface CropHandleProps {
handle: CropHandle;
onDragStart: (handle: CropHandle, event: React.MouseEvent | React.TouchEvent) => void;
position:
| 'top-left'
| 'top-right'
| 'bottom-left'
| 'bottom-right'
| 'top'
| 'bottom'
| 'left'
| 'right';
}
const CropHandle: React.FC<CropHandleProps> = ({ handle, onDragStart, position }) => {
const handleSize = 24;
const handleVisualSize = 12;
const positionStyles: Record<string, React.CSSProperties> = {
'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 (
<Box
onMouseDown={(e) => 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],
}}
>
<Box
sx={{
width: isCorner ? handleVisualSize : position === 'top' || position === 'bottom' ? 24 : 8,
height: isCorner ? handleVisualSize : position === 'left' || position === 'right' ? 24 : 8,
backgroundColor: 'white',
borderRadius: isCorner ? '50%' : 1,
boxShadow: '0 2px 4px rgba(0,0,0,0.3)',
}}
/>
</Box>
);
};
interface CropHandleAreaProps {
handle: CropHandle;
onDragStart: (handle: CropHandle, event: React.MouseEvent | React.TouchEvent) => void;
sx?: React.CSSProperties & { [key: string]: unknown };
}
const CropHandleArea: React.FC<CropHandleAreaProps> = ({ handle, onDragStart, sx }) => {
return (
<Box
onMouseDown={(e) => onDragStart(handle, e)}
onTouchStart={(e) => onDragStart(handle, e)}
sx={sx}
/>
);
};
export default CropTool;