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
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>
446 lines
13 KiB
TypeScript
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;
|