feat: add camera capture component (refs #66)
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
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>
This commit is contained in:
396
frontend/src/shared/components/CameraCapture/CropTool.tsx
Normal file
396
frontend/src/shared/components/CameraCapture/CropTool.tsx
Normal file
@@ -0,0 +1,396 @@
|
||||
/**
|
||||
* @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;
|
||||
Reference in New Issue
Block a user