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>
383 lines
10 KiB
TypeScript
383 lines
10 KiB
TypeScript
/**
|
|
* @ai-summary Main camera capture component with viewfinder, crop tool, and file fallback
|
|
* @ai-context Orchestrates camera access, photo capture, cropping, and file selection
|
|
*/
|
|
|
|
import React, { useState, useCallback, useEffect } from 'react';
|
|
import { Box, Alert, Button, Typography, CircularProgress } from '@mui/material';
|
|
import CameraAltIcon from '@mui/icons-material/CameraAlt';
|
|
import UploadFileIcon from '@mui/icons-material/UploadFile';
|
|
import type { CameraCaptureProps, CaptureState } from './types';
|
|
import {
|
|
DEFAULT_ACCEPTED_FORMATS,
|
|
DEFAULT_MAX_FILE_SIZE,
|
|
GUIDANCE_CONFIGS,
|
|
getInitialCropForGuidance,
|
|
} from './types';
|
|
import { useCameraPermission } from './useCameraPermission';
|
|
import { CameraViewfinder } from './CameraViewfinder';
|
|
import { CropTool } from './CropTool';
|
|
import { FileInputFallback } from './FileInputFallback';
|
|
|
|
export const CameraCapture: React.FC<CameraCaptureProps> = ({
|
|
onCapture,
|
|
onCancel,
|
|
guidanceType = 'none',
|
|
allowCrop = true,
|
|
maxFileSize = DEFAULT_MAX_FILE_SIZE,
|
|
acceptedFormats = DEFAULT_ACCEPTED_FORMATS,
|
|
}) => {
|
|
const [captureState, setCaptureState] = useState<CaptureState>('idle');
|
|
const [capturedImageSrc, setCapturedImageSrc] = useState<string | null>(null);
|
|
const [capturedFile, setCapturedFile] = useState<File | null>(null);
|
|
const [useFallback, setUseFallback] = useState(false);
|
|
|
|
const {
|
|
permissionState,
|
|
stream,
|
|
error: cameraError,
|
|
facingMode,
|
|
hasMultipleCameras,
|
|
requestPermission,
|
|
switchCamera,
|
|
stopStream,
|
|
} = useCameraPermission();
|
|
|
|
// Request camera permission on mount
|
|
useEffect(() => {
|
|
if (captureState === 'idle' && !useFallback) {
|
|
setCaptureState('viewfinder');
|
|
requestPermission();
|
|
}
|
|
}, [captureState, useFallback, requestPermission]);
|
|
|
|
// Clean up on unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
stopStream();
|
|
if (capturedImageSrc) {
|
|
URL.revokeObjectURL(capturedImageSrc);
|
|
}
|
|
};
|
|
}, [stopStream, capturedImageSrc]);
|
|
|
|
const handleCapture = useCallback(() => {
|
|
if (!stream) return;
|
|
|
|
// Get video element from stream
|
|
const videoTracks = stream.getVideoTracks();
|
|
if (videoTracks.length === 0) return;
|
|
|
|
const videoTrack = videoTracks[0];
|
|
const settings = videoTrack.getSettings();
|
|
|
|
// Create canvas for capture
|
|
const canvas = document.createElement('canvas');
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) return;
|
|
|
|
// Find the video element to capture from
|
|
const videoElements = document.querySelectorAll('video');
|
|
let sourceVideo: HTMLVideoElement | null = null;
|
|
|
|
for (const video of videoElements) {
|
|
if (video.srcObject === stream) {
|
|
sourceVideo = video;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!sourceVideo) return;
|
|
|
|
// Set canvas size to video dimensions
|
|
canvas.width = settings.width || sourceVideo.videoWidth || 1920;
|
|
canvas.height = settings.height || sourceVideo.videoHeight || 1080;
|
|
|
|
// Draw video frame to canvas
|
|
ctx.drawImage(sourceVideo, 0, 0, canvas.width, canvas.height);
|
|
|
|
// Convert to blob
|
|
canvas.toBlob(
|
|
(blob) => {
|
|
if (!blob) return;
|
|
|
|
// Create file from blob
|
|
const file = new File([blob], `capture-${Date.now()}.jpg`, {
|
|
type: 'image/jpeg',
|
|
});
|
|
|
|
setCapturedFile(file);
|
|
setCapturedImageSrc(URL.createObjectURL(blob));
|
|
|
|
if (allowCrop) {
|
|
setCaptureState('cropping');
|
|
} else {
|
|
// Skip cropping, directly complete
|
|
onCapture(file);
|
|
stopStream();
|
|
}
|
|
},
|
|
'image/jpeg',
|
|
0.92
|
|
);
|
|
}, [stream, allowCrop, onCapture, stopStream]);
|
|
|
|
const handleCropConfirm = useCallback(
|
|
(croppedBlob: Blob) => {
|
|
if (!capturedFile) return;
|
|
|
|
const croppedFile = new File(
|
|
[croppedBlob],
|
|
`cropped-${capturedFile.name}`,
|
|
{ type: croppedBlob.type }
|
|
);
|
|
|
|
onCapture(capturedFile, croppedFile);
|
|
stopStream();
|
|
},
|
|
[capturedFile, onCapture, stopStream]
|
|
);
|
|
|
|
const handleCropSkip = useCallback(() => {
|
|
if (!capturedFile) return;
|
|
onCapture(capturedFile);
|
|
stopStream();
|
|
}, [capturedFile, onCapture, stopStream]);
|
|
|
|
const handleRetake = useCallback(() => {
|
|
if (capturedImageSrc) {
|
|
URL.revokeObjectURL(capturedImageSrc);
|
|
}
|
|
setCapturedFile(null);
|
|
setCapturedImageSrc(null);
|
|
setCaptureState('viewfinder');
|
|
}, [capturedImageSrc]);
|
|
|
|
const handleCropReset = useCallback(() => {
|
|
// Just reset crop area, keep the captured image
|
|
}, []);
|
|
|
|
const handleCancel = useCallback(() => {
|
|
stopStream();
|
|
if (capturedImageSrc) {
|
|
URL.revokeObjectURL(capturedImageSrc);
|
|
}
|
|
onCancel();
|
|
}, [stopStream, capturedImageSrc, onCancel]);
|
|
|
|
const handleFileSelect = useCallback(
|
|
(file: File) => {
|
|
setCapturedFile(file);
|
|
setCapturedImageSrc(URL.createObjectURL(file));
|
|
|
|
if (allowCrop) {
|
|
setCaptureState('cropping');
|
|
} else {
|
|
onCapture(file);
|
|
}
|
|
},
|
|
[allowCrop, onCapture]
|
|
);
|
|
|
|
const handleSwitchToFallback = useCallback(() => {
|
|
stopStream();
|
|
setUseFallback(true);
|
|
}, [stopStream]);
|
|
|
|
const handleSwitchToCamera = useCallback(() => {
|
|
setUseFallback(false);
|
|
setCaptureState('idle');
|
|
}, []);
|
|
|
|
// Get aspect ratio for crop tool if guidance type is set
|
|
const cropAspectRatio =
|
|
guidanceType !== 'none' ? GUIDANCE_CONFIGS[guidanceType].aspectRatio : undefined;
|
|
|
|
// Calculate crop coordinates centered on the guidance overlay area
|
|
const cropInitialArea = getInitialCropForGuidance(guidanceType);
|
|
|
|
// Render permission error state
|
|
if (permissionState === 'denied' && !useFallback) {
|
|
return (
|
|
<PermissionErrorView
|
|
error={cameraError || 'Camera permission denied'}
|
|
onUseFileInput={handleSwitchToFallback}
|
|
onCancel={handleCancel}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// Render unavailable state
|
|
if (permissionState === 'unavailable' && !useFallback) {
|
|
return (
|
|
<PermissionErrorView
|
|
error={cameraError || 'Camera not available'}
|
|
onUseFileInput={handleSwitchToFallback}
|
|
onCancel={handleCancel}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// Render file input fallback
|
|
if (useFallback) {
|
|
return (
|
|
<Box sx={{ width: '100%', height: '100%' }}>
|
|
<FileInputFallback
|
|
onFileSelect={handleFileSelect}
|
|
onCancel={handleCancel}
|
|
acceptedFormats={acceptedFormats}
|
|
maxFileSize={maxFileSize}
|
|
/>
|
|
{/* Option to switch back to camera if available */}
|
|
{permissionState !== 'unavailable' && permissionState !== 'denied' && (
|
|
<Box sx={{ p: 2, textAlign: 'center' }}>
|
|
<Button
|
|
onClick={handleSwitchToCamera}
|
|
startIcon={<CameraAltIcon />}
|
|
variant="text"
|
|
>
|
|
Use Camera Instead
|
|
</Button>
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
// Render crop tool
|
|
if (captureState === 'cropping' && capturedImageSrc) {
|
|
return (
|
|
<CropTool
|
|
imageSrc={capturedImageSrc}
|
|
initialCrop={cropInitialArea}
|
|
lockAspectRatio={guidanceType !== 'none' && guidanceType !== 'vin'}
|
|
aspectRatio={cropAspectRatio}
|
|
onConfirm={handleCropConfirm}
|
|
onReset={handleCropReset}
|
|
onRetake={handleRetake}
|
|
onSkip={handleCropSkip}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// Render loading state
|
|
if (!stream && permissionState === 'prompt') {
|
|
return (
|
|
<Box
|
|
sx={{
|
|
width: '100%',
|
|
height: '100%',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
backgroundColor: 'black',
|
|
color: 'white',
|
|
gap: 2,
|
|
}}
|
|
>
|
|
<CircularProgress color="inherit" />
|
|
<Typography>Requesting camera access...</Typography>
|
|
<Button
|
|
onClick={handleSwitchToFallback}
|
|
startIcon={<UploadFileIcon />}
|
|
sx={{ color: 'white', mt: 2 }}
|
|
>
|
|
Upload File Instead
|
|
</Button>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
// Render viewfinder
|
|
return (
|
|
<Box sx={{ width: '100%', height: '100%', position: 'relative' }}>
|
|
<CameraViewfinder
|
|
stream={stream}
|
|
guidanceType={guidanceType}
|
|
onCapture={handleCapture}
|
|
onCancel={handleCancel}
|
|
onSwitchCamera={switchCamera}
|
|
canSwitchCamera={hasMultipleCameras}
|
|
facingMode={facingMode}
|
|
/>
|
|
|
|
{/* Fallback option button */}
|
|
<Box
|
|
sx={{
|
|
position: 'absolute',
|
|
bottom: 100,
|
|
left: '50%',
|
|
transform: 'translateX(-50%)',
|
|
zIndex: 10,
|
|
}}
|
|
>
|
|
<Button
|
|
onClick={handleSwitchToFallback}
|
|
startIcon={<UploadFileIcon />}
|
|
size="small"
|
|
sx={{
|
|
color: 'white',
|
|
backgroundColor: 'rgba(0, 0, 0, 0.3)',
|
|
'&:hover': {
|
|
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
|
},
|
|
}}
|
|
>
|
|
Upload File
|
|
</Button>
|
|
</Box>
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
interface PermissionErrorViewProps {
|
|
error: string;
|
|
onUseFileInput: () => void;
|
|
onCancel: () => void;
|
|
}
|
|
|
|
const PermissionErrorView: React.FC<PermissionErrorViewProps> = ({
|
|
error,
|
|
onUseFileInput,
|
|
onCancel,
|
|
}) => (
|
|
<Box
|
|
sx={{
|
|
width: '100%',
|
|
height: '100%',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
p: 3,
|
|
gap: 3,
|
|
backgroundColor: 'background.paper',
|
|
}}
|
|
>
|
|
<Alert severity="warning" sx={{ maxWidth: 400 }}>
|
|
{error}
|
|
</Alert>
|
|
|
|
<Typography variant="body2" color="text.secondary" textAlign="center">
|
|
You can still upload images from your device
|
|
</Typography>
|
|
|
|
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap', justifyContent: 'center' }}>
|
|
<Button
|
|
variant="contained"
|
|
onClick={onUseFileInput}
|
|
startIcon={<UploadFileIcon />}
|
|
>
|
|
Upload File
|
|
</Button>
|
|
|
|
<Button variant="outlined" onClick={onCancel}>
|
|
Cancel
|
|
</Button>
|
|
</Box>
|
|
</Box>
|
|
);
|
|
|
|
export default CameraCapture;
|