Files
motovaultpro/frontend/src/shared/components/CameraCapture/CropTool.tsx
Eric Gullickson efc55cd3db
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 3m20s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 51s
Deploy to Staging / Verify Staging (pull_request) Successful in 9s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 7s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
feat: improve VIN camera crop overlay-to-crop alignment and touch targets (refs #123)
Bridge guidance overlay position to crop tool initial coordinates so the
crop box appears centered matching the viewfinder guide. Increase handle
touch targets to 44px (32px on compact viewports) for mobile usability.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 20:05:40 -06:00

446 lines
13 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, 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<CropToolProps> = ({
imageSrc,
lockAspectRatio = false,
initialCrop,
aspectRatio,
onConfirm,
onReset,
onRetake,
onSkip,
}) => {
const [isProcessing, setIsProcessing] = useState(false);
const imageAreaRef = useRef<HTMLDivElement>(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 (
<Box
sx={{
position: 'relative',
width: '100%',
height: '100%',
backgroundColor: 'black',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}
>
{/* Image with crop overlay */}
<Box
ref={imageAreaRef}
sx={{
flex: 1,
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
p: 2,
}}
>
<Box
data-crop-container
sx={{
position: 'relative',
userSelect: 'none',
touchAction: isDragging ? 'none' : 'auto',
}}
>
{/* Source image */}
<img
src={imageSrc}
alt="Captured"
style={{
maxWidth: '100%',
maxHeight: imageMaxHeight > 0 ? `${imageMaxHeight}px` : '70vh',
display: 'block',
}}
draggable={false}
/>
{/* Draw surface for free-form rectangle drawing */}
{!cropDrawn && (
<Box
onMouseDown={handleDrawStart}
onTouchStart={handleDrawStart}
sx={{
position: 'absolute',
inset: 0,
cursor: 'crosshair',
zIndex: 5,
touchAction: 'none',
}}
/>
)}
{/* Dark overlay outside crop area */}
{showCropArea && (
<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 border and handles */}
{showCropArea && (
<Box
sx={{
position: 'absolute',
top: `${cropArea.y}%`,
left: `${cropArea.x}%`,
width: `${cropArea.width}%`,
height: `${cropArea.height}%`,
border: '2px solid white',
boxSizing: 'border-box',
}}
>
{/* Handles only appear after drawing is complete */}
{cropDrawn && (
<>
{/* Move handle (center area) */}
<CropHandleArea
handle="move"
onDragStart={handleDragStart}
sx={{
position: 'absolute',
inset: handleConfig.size / 2,
cursor: 'move',
}}
/>
{/* Corner handles */}
<CropHandle handle="nw" onDragStart={handleDragStart} position="top-left" config={handleConfig} />
<CropHandle handle="ne" onDragStart={handleDragStart} position="top-right" config={handleConfig} />
<CropHandle handle="sw" onDragStart={handleDragStart} position="bottom-left" config={handleConfig} />
<CropHandle handle="se" onDragStart={handleDragStart} position="bottom-right" config={handleConfig} />
{/* Edge handles */}
<CropHandle handle="n" onDragStart={handleDragStart} position="top" config={handleConfig} />
<CropHandle handle="s" onDragStart={handleDragStart} position="bottom" config={handleConfig} />
<CropHandle handle="w" onDragStart={handleDragStart} position="left" config={handleConfig} />
<CropHandle handle="e" onDragStart={handleDragStart} position="right" config={handleConfig} />
</>
)}
{/* 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)' }}>
{cropDrawn ? 'Drag handles to adjust crop area' : 'Tap and drag to select 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 || !cropDrawn}
>
Reset
</Button>
<Button
onClick={onSkip}
startIcon={<SkipNextIcon />}
sx={{ color: 'white' }}
disabled={isProcessing}
>
Skip
</Button>
<IconButton
onClick={handleConfirm}
disabled={isProcessing || !cropDrawn}
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;
config: { size: number; visual: number };
position:
| 'top-left'
| 'top-right'
| 'bottom-left'
| 'bottom-right'
| 'top'
| 'bottom'
| 'left'
| 'right';
}
const CropHandle: React.FC<CropHandleProps> = ({ handle, onDragStart, position, config }) => {
const handleSize = config.size;
const handleVisualSize = config.visual;
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={{
// Edge handles: 70% of touch target for long dimension, 75% of visual for short dimension
width: isCorner ? handleVisualSize : position === 'top' || position === 'bottom' ? Math.round(handleSize * 0.7) : Math.round(handleVisualSize * 0.75),
height: isCorner ? handleVisualSize : position === 'left' || position === 'right' ? Math.round(handleSize * 0.7) : Math.round(handleVisualSize * 0.75),
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;