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
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>
397 lines
11 KiB
TypeScript
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;
|