feat: add camera capture component (#66) #73

Merged
egullickson merged 1 commits from issue-66-camera-capture-component into main 2026-02-01 21:24:27 +00:00
11 changed files with 2439 additions and 0 deletions
Showing only changes of commit 7c8b6fda2a - Show all commits

View File

@@ -0,0 +1,32 @@
# frontend/src/shared/
Shared components that are reusable across multiple features.
## Subdirectories
| Directory | What | When to read |
| --------- | ---- | ------------ |
| `components/CameraCapture/` | Camera capture with crop tool | Building OCR features, image upload |
## Components
### CameraCapture
Full-featured camera capture component with:
- `getUserMedia` API for camera access
- Translucent guidance overlays (VIN, receipt, document)
- Post-capture crop tool
- File input fallback for desktop/unsupported browsers
- HEIC, JPEG, PNG support
Usage:
```tsx
import { CameraCapture } from '@/shared/components/CameraCapture';
<CameraCapture
onCapture={(file, croppedFile) => handleCapture(file, croppedFile)}
onCancel={() => setShowCamera(false)}
guidanceType="vin"
allowCrop={true}
/>
```

View File

@@ -0,0 +1,362 @@
/**
* @ai-summary Tests for CameraCapture component
* @ai-context Validates camera permission, capture flow, cropping, and file fallback
*/
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import { CameraCapture } from './CameraCapture';
import { FileInputFallback } from './FileInputFallback';
import { GuidanceOverlay } from './GuidanceOverlay';
// Mock MUI useMediaQuery
jest.mock('@mui/material', () => ({
...jest.requireActual('@mui/material'),
useMediaQuery: jest.fn(() => false),
}));
// Mock navigator.mediaDevices
const mockGetUserMedia = jest.fn();
const mockEnumerateDevices = jest.fn();
const mockMediaStream = {
getTracks: () => [{ stop: jest.fn() }],
getVideoTracks: () => [
{
getSettings: () => ({ width: 1920, height: 1080 }),
stop: jest.fn(),
},
],
};
beforeAll(() => {
Object.defineProperty(navigator, 'mediaDevices', {
value: {
getUserMedia: mockGetUserMedia,
enumerateDevices: mockEnumerateDevices,
},
writable: true,
});
});
beforeEach(() => {
jest.clearAllMocks();
mockEnumerateDevices.mockResolvedValue([
{ kind: 'videoinput', deviceId: 'camera1' },
]);
});
describe('CameraCapture', () => {
const mockOnCapture = jest.fn();
const mockOnCancel = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
});
describe('Permission handling', () => {
it('shows loading state while requesting permission', () => {
mockGetUserMedia.mockImplementation(() => new Promise(() => {})); // Never resolves
render(
<CameraCapture onCapture={mockOnCapture} onCancel={mockOnCancel} />
);
expect(screen.getByText(/requesting camera access/i)).toBeInTheDocument();
});
it('shows error when permission denied', async () => {
mockGetUserMedia.mockRejectedValue(
new DOMException('Permission denied', 'NotAllowedError')
);
render(
<CameraCapture onCapture={mockOnCapture} onCancel={mockOnCancel} />
);
await waitFor(() => {
expect(screen.getByText(/camera permission/i)).toBeInTheDocument();
});
expect(screen.getByRole('button', { name: /upload file/i })).toBeInTheDocument();
});
it('shows error when camera unavailable', async () => {
mockGetUserMedia.mockRejectedValue(
new DOMException('No camera', 'NotFoundError')
);
render(
<CameraCapture onCapture={mockOnCapture} onCancel={mockOnCancel} />
);
await waitFor(() => {
expect(screen.getByText(/no camera found/i)).toBeInTheDocument();
});
});
});
describe('Viewfinder', () => {
it('shows viewfinder when camera access granted', async () => {
mockGetUserMedia.mockResolvedValue(mockMediaStream);
render(
<CameraCapture onCapture={mockOnCapture} onCancel={mockOnCancel} />
);
await waitFor(() => {
expect(screen.getByRole('button', { name: /take photo/i })).toBeInTheDocument();
});
});
it('shows cancel button in viewfinder', async () => {
mockGetUserMedia.mockResolvedValue(mockMediaStream);
render(
<CameraCapture onCapture={mockOnCapture} onCancel={mockOnCancel} />
);
await waitFor(() => {
expect(screen.getByRole('button', { name: /cancel capture/i })).toBeInTheDocument();
});
});
it('calls onCancel when cancel button clicked', async () => {
mockGetUserMedia.mockResolvedValue(mockMediaStream);
render(
<CameraCapture onCapture={mockOnCapture} onCancel={mockOnCancel} />
);
await waitFor(() => {
expect(screen.getByRole('button', { name: /cancel capture/i })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: /cancel capture/i }));
expect(mockOnCancel).toHaveBeenCalledTimes(1);
});
});
describe('Guidance overlay', () => {
it('shows VIN guidance when guidanceType is vin', async () => {
mockGetUserMedia.mockResolvedValue(mockMediaStream);
render(
<CameraCapture
onCapture={mockOnCapture}
onCancel={mockOnCancel}
guidanceType="vin"
/>
);
await waitFor(() => {
expect(screen.getByText(/position vin/i)).toBeInTheDocument();
});
});
it('shows receipt guidance when guidanceType is receipt', async () => {
mockGetUserMedia.mockResolvedValue(mockMediaStream);
render(
<CameraCapture
onCapture={mockOnCapture}
onCancel={mockOnCancel}
guidanceType="receipt"
/>
);
await waitFor(() => {
expect(screen.getByText(/position receipt/i)).toBeInTheDocument();
});
});
});
describe('File fallback', () => {
it('shows upload file button in viewfinder', async () => {
mockGetUserMedia.mockResolvedValue(mockMediaStream);
render(
<CameraCapture onCapture={mockOnCapture} onCancel={mockOnCancel} />
);
await waitFor(() => {
expect(screen.getByRole('button', { name: /upload file/i })).toBeInTheDocument();
});
});
it('switches to file fallback when upload file clicked', async () => {
mockGetUserMedia.mockResolvedValue(mockMediaStream);
render(
<CameraCapture onCapture={mockOnCapture} onCancel={mockOnCancel} />
);
await waitFor(() => {
expect(screen.getByRole('button', { name: /upload file/i })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: /upload file/i }));
await waitFor(() => {
expect(screen.getByText(/upload image/i)).toBeInTheDocument();
});
});
});
});
describe('FileInputFallback', () => {
const mockOnFileSelect = jest.fn();
const mockOnCancel = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
});
it('renders upload area', () => {
render(
<FileInputFallback
onFileSelect={mockOnFileSelect}
onCancel={mockOnCancel}
acceptedFormats={['image/jpeg', 'image/png']}
maxFileSize={10 * 1024 * 1024}
/>
);
expect(screen.getByText(/drag and drop/i)).toBeInTheDocument();
});
it('shows accepted formats', () => {
render(
<FileInputFallback
onFileSelect={mockOnFileSelect}
onCancel={mockOnCancel}
acceptedFormats={['image/jpeg', 'image/png', 'image/heic']}
maxFileSize={10 * 1024 * 1024}
/>
);
expect(screen.getByText(/jpeg, png, heic/i)).toBeInTheDocument();
});
it('shows max file size', () => {
render(
<FileInputFallback
onFileSelect={mockOnFileSelect}
onCancel={mockOnCancel}
acceptedFormats={['image/jpeg']}
maxFileSize={10 * 1024 * 1024}
/>
);
expect(screen.getByText(/10mb/i)).toBeInTheDocument();
});
it('calls onCancel when cancel clicked', () => {
render(
<FileInputFallback
onFileSelect={mockOnFileSelect}
onCancel={mockOnCancel}
acceptedFormats={['image/jpeg']}
maxFileSize={10 * 1024 * 1024}
/>
);
fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
expect(mockOnCancel).toHaveBeenCalledTimes(1);
});
it('shows error for invalid file type', async () => {
render(
<FileInputFallback
onFileSelect={mockOnFileSelect}
onCancel={mockOnCancel}
acceptedFormats={['image/jpeg']}
maxFileSize={10 * 1024 * 1024}
/>
);
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
const invalidFile = new File(['test'], 'test.txt', { type: 'text/plain' });
Object.defineProperty(input, 'files', {
value: [invalidFile],
});
fireEvent.change(input);
await waitFor(() => {
expect(screen.getByText(/invalid file type/i)).toBeInTheDocument();
});
});
it('shows error for file too large', async () => {
render(
<FileInputFallback
onFileSelect={mockOnFileSelect}
onCancel={mockOnCancel}
acceptedFormats={['image/jpeg']}
maxFileSize={1024} // 1KB
/>
);
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
const largeFile = new File(['x'.repeat(2048)], 'large.jpg', {
type: 'image/jpeg',
});
Object.defineProperty(input, 'files', {
value: [largeFile],
});
fireEvent.change(input);
await waitFor(() => {
expect(screen.getByText(/file too large/i)).toBeInTheDocument();
});
});
it('calls onFileSelect with valid file', async () => {
render(
<FileInputFallback
onFileSelect={mockOnFileSelect}
onCancel={mockOnCancel}
acceptedFormats={['image/jpeg']}
maxFileSize={10 * 1024 * 1024}
/>
);
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
const validFile = new File(['test'], 'test.jpg', { type: 'image/jpeg' });
Object.defineProperty(input, 'files', {
value: [validFile],
});
fireEvent.change(input);
await waitFor(() => {
expect(mockOnFileSelect).toHaveBeenCalledWith(validFile);
});
});
});
describe('GuidanceOverlay', () => {
it('renders nothing when type is none', () => {
const { container } = render(<GuidanceOverlay type="none" />);
expect(container.firstChild).toBeNull();
});
it('renders VIN guidance with correct description', () => {
render(<GuidanceOverlay type="vin" />);
expect(screen.getByText(/position vin plate/i)).toBeInTheDocument();
});
it('renders receipt guidance with correct description', () => {
render(<GuidanceOverlay type="receipt" />);
expect(screen.getByText(/position receipt/i)).toBeInTheDocument();
});
it('renders document guidance with correct description', () => {
render(<GuidanceOverlay type="document" />);
expect(screen.getByText(/position document/i)).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,377 @@
/**
* @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,
} 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;
// 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}
lockAspectRatio={guidanceType !== 'none'}
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;

View File

@@ -0,0 +1,178 @@
/**
* @ai-summary Camera viewfinder with live preview and capture controls
* @ai-context Displays video stream with guidance overlay and capture/cancel buttons
*/
import React, { useRef, useEffect } from 'react';
import { Box, IconButton, Typography } from '@mui/material';
import CameraAltIcon from '@mui/icons-material/CameraAlt';
import CloseIcon from '@mui/icons-material/Close';
import CameraswitchIcon from '@mui/icons-material/Cameraswitch';
import type { CameraViewfinderProps } from './types';
import { GuidanceOverlay } from './GuidanceOverlay';
export const CameraViewfinder: React.FC<CameraViewfinderProps> = ({
stream,
guidanceType,
onCapture,
onCancel,
onSwitchCamera,
canSwitchCamera,
facingMode,
}) => {
const videoRef = useRef<HTMLVideoElement>(null);
// Attach stream to video element
useEffect(() => {
if (videoRef.current && stream) {
videoRef.current.srcObject = stream;
}
return () => {
if (videoRef.current) {
videoRef.current.srcObject = null;
}
};
}, [stream]);
return (
<Box
sx={{
position: 'relative',
width: '100%',
height: '100%',
backgroundColor: 'black',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}
>
{/* Video preview */}
<Box
sx={{
flex: 1,
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
}}
>
<video
ref={videoRef}
autoPlay
playsInline
muted
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
// Mirror front camera for natural preview
transform: facingMode === 'user' ? 'scaleX(-1)' : 'none',
}}
/>
{/* Guidance overlay */}
<GuidanceOverlay type={guidanceType} />
{/* Top bar with close and switch camera */}
<Box
sx={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
p: 2,
background: 'linear-gradient(to bottom, rgba(0,0,0,0.5) 0%, transparent 100%)',
}}
>
<IconButton
onClick={onCancel}
aria-label="Cancel capture"
sx={{
color: 'white',
backgroundColor: 'rgba(0, 0, 0, 0.3)',
'&:hover': {
backgroundColor: 'rgba(0, 0, 0, 0.5)',
},
}}
>
<CloseIcon />
</IconButton>
{canSwitchCamera && (
<IconButton
onClick={onSwitchCamera}
aria-label="Switch camera"
sx={{
color: 'white',
backgroundColor: 'rgba(0, 0, 0, 0.3)',
'&:hover': {
backgroundColor: 'rgba(0, 0, 0, 0.5)',
},
}}
>
<CameraswitchIcon />
</IconButton>
)}
</Box>
</Box>
{/* Bottom controls */}
<Box
sx={{
flexShrink: 0,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 1,
py: 3,
px: 2,
background: 'linear-gradient(to top, rgba(0,0,0,0.8) 0%, rgba(0,0,0,0.5) 100%)',
}}
>
{/* Capture button */}
<IconButton
onClick={onCapture}
aria-label="Take photo"
sx={{
width: 72,
height: 72,
backgroundColor: 'white',
border: '4px solid rgba(255, 255, 255, 0.5)',
'&:hover': {
backgroundColor: 'rgba(255, 255, 255, 0.9)',
},
'&:active': {
transform: 'scale(0.95)',
backgroundColor: 'rgba(255, 255, 255, 0.8)',
},
transition: 'transform 0.1s ease',
}}
>
<CameraAltIcon
sx={{
fontSize: 32,
color: 'black',
}}
/>
</IconButton>
<Typography
variant="caption"
sx={{
color: 'rgba(255, 255, 255, 0.7)',
textAlign: 'center',
}}
>
Tap to capture
</Typography>
</Box>
</Box>
);
};
export default CameraViewfinder;

View 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;

View File

@@ -0,0 +1,253 @@
/**
* @ai-summary File input fallback for desktop and unsupported browsers
* @ai-context Provides drag-drop and click-to-browse file selection
*/
import React, { useRef, useState, useCallback } from 'react';
import { Box, Typography, Button, Alert } from '@mui/material';
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
import CloseIcon from '@mui/icons-material/Close';
import type { FileInputFallbackProps } from './types';
export const FileInputFallback: React.FC<FileInputFallbackProps> = ({
onFileSelect,
onCancel,
acceptedFormats,
maxFileSize,
}) => {
const inputRef = useRef<HTMLInputElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [error, setError] = useState<string | null>(null);
const validateFile = useCallback(
(file: File): string | null => {
// Check file type
const isValidType =
acceptedFormats.length === 0 ||
acceptedFormats.some((format) => {
// Handle HEIC/HEIF which may have different MIME types
if (format === 'image/heic' || format === 'image/heif') {
return (
file.type === 'image/heic' ||
file.type === 'image/heif' ||
file.name.toLowerCase().endsWith('.heic') ||
file.name.toLowerCase().endsWith('.heif')
);
}
return file.type === format;
});
if (!isValidType) {
return `Invalid file type. Accepted formats: ${acceptedFormats.join(', ')}`;
}
// Check file size
if (file.size > maxFileSize) {
const maxSizeMB = (maxFileSize / (1024 * 1024)).toFixed(1);
return `File too large. Maximum size: ${maxSizeMB}MB`;
}
return null;
},
[acceptedFormats, maxFileSize]
);
const handleFile = useCallback(
(file: File) => {
setError(null);
const validationError = validateFile(file);
if (validationError) {
setError(validationError);
return;
}
onFileSelect(file);
},
[validateFile, onFileSelect]
);
const handleInputChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
handleFile(file);
}
// Reset input so same file can be selected again
if (inputRef.current) {
inputRef.current.value = '';
}
},
[handleFile]
);
const handleDragOver = useCallback((event: React.DragEvent) => {
event.preventDefault();
event.stopPropagation();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback((event: React.DragEvent) => {
event.preventDefault();
event.stopPropagation();
setIsDragging(false);
}, []);
const handleDrop = useCallback(
(event: React.DragEvent) => {
event.preventDefault();
event.stopPropagation();
setIsDragging(false);
const file = event.dataTransfer.files[0];
if (file) {
handleFile(file);
}
},
[handleFile]
);
const handleClick = useCallback(() => {
inputRef.current?.click();
}, []);
// Generate accept string for input
const acceptString = acceptedFormats.join(',');
return (
<Box
sx={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
backgroundColor: 'background.paper',
}}
>
{/* Header */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
p: 2,
borderBottom: 1,
borderColor: 'divider',
}}
>
<Typography variant="h6">Upload Image</Typography>
<Button
onClick={onCancel}
startIcon={<CloseIcon />}
color="inherit"
aria-label="Cancel"
>
Cancel
</Button>
</Box>
{/* Drop zone */}
<Box
sx={{
flex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
p: 3,
}}
>
<Box
onClick={handleClick}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
sx={{
width: '100%',
maxWidth: 400,
py: 6,
px: 4,
border: 2,
borderStyle: 'dashed',
borderColor: isDragging ? 'primary.main' : 'divider',
borderRadius: 2,
backgroundColor: isDragging ? 'action.hover' : 'transparent',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 2,
cursor: 'pointer',
transition: 'all 0.2s ease',
'&:hover': {
borderColor: 'primary.main',
backgroundColor: 'action.hover',
},
}}
>
<CloudUploadIcon
sx={{
fontSize: 64,
color: isDragging ? 'primary.main' : 'text.secondary',
}}
/>
<Typography
variant="body1"
color={isDragging ? 'primary.main' : 'text.primary'}
textAlign="center"
>
{isDragging
? 'Drop image here'
: 'Drag and drop an image, or click to browse'}
</Typography>
<Typography variant="caption" color="text.secondary" textAlign="center">
Supported formats: JPEG, PNG, HEIC
</Typography>
<Typography variant="caption" color="text.secondary">
Maximum size: {(maxFileSize / (1024 * 1024)).toFixed(0)}MB
</Typography>
</Box>
{error && (
<Alert severity="error" sx={{ mt: 2, maxWidth: 400, width: '100%' }}>
{error}
</Alert>
)}
{/* Hidden file input */}
<input
ref={inputRef}
type="file"
accept={acceptString}
onChange={handleInputChange}
style={{ display: 'none' }}
aria-label="Select image file"
/>
</Box>
{/* Mobile-friendly button */}
<Box
sx={{
p: 2,
borderTop: 1,
borderColor: 'divider',
display: { xs: 'block', sm: 'none' },
}}
>
<Button
variant="contained"
fullWidth
onClick={handleClick}
startIcon={<CloudUploadIcon />}
size="large"
>
Choose Image
</Button>
</Box>
</Box>
);
};
export default FileInputFallback;

View File

@@ -0,0 +1,145 @@
/**
* @ai-summary Translucent guidance overlay for camera viewfinder
* @ai-context Displays aspect-ratio guides for VIN (~6:1), receipt (~2:3), and documents
*/
import React from 'react';
import { Box, Typography } from '@mui/material';
import type { GuidanceOverlayProps, GuidanceType } from './types';
import { GUIDANCE_CONFIGS } from './types';
/** Calculate overlay dimensions based on guidance type and container */
function getOverlayStyles(type: GuidanceType): React.CSSProperties {
if (type === 'none') {
return { display: 'none' };
}
const config = GUIDANCE_CONFIGS[type];
// For VIN (wide), use width-constrained layout
// For receipt/document (tall), use height-constrained layout
if (config.aspectRatio > 1) {
// Wide aspect ratio (VIN)
return {
width: '85%',
height: 'auto',
aspectRatio: `${config.aspectRatio}`,
maxHeight: '30%',
};
} else {
// Tall aspect ratio (receipt, document)
return {
height: '70%',
width: 'auto',
aspectRatio: `${config.aspectRatio}`,
maxWidth: '85%',
};
}
}
export const GuidanceOverlay: React.FC<GuidanceOverlayProps> = ({ type }) => {
if (type === 'none') {
return null;
}
const config = GUIDANCE_CONFIGS[type];
const overlayStyles = getOverlayStyles(type);
return (
<Box
sx={{
position: 'absolute',
inset: 0,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
pointerEvents: 'none',
zIndex: 1,
}}
>
{/* Guide box */}
<Box
sx={{
...overlayStyles,
border: '2px dashed rgba(255, 255, 255, 0.7)',
borderRadius: 1,
backgroundColor: 'rgba(255, 255, 255, 0.1)',
boxShadow: 'inset 0 0 20px rgba(255, 255, 255, 0.1)',
position: 'relative',
}}
>
{/* Corner indicators */}
<CornerIndicator position="top-left" />
<CornerIndicator position="top-right" />
<CornerIndicator position="bottom-left" />
<CornerIndicator position="bottom-right" />
</Box>
{/* Instruction text */}
<Typography
variant="body2"
sx={{
color: 'white',
mt: 2,
px: 2,
py: 1,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
borderRadius: 1,
textAlign: 'center',
}}
>
{config.description}
</Typography>
</Box>
);
};
interface CornerIndicatorProps {
position: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
}
const CornerIndicator: React.FC<CornerIndicatorProps> = ({ position }) => {
const size = 20;
const thickness = 3;
const positionStyles: Record<string, React.CSSProperties> = {
'top-left': {
top: -thickness / 2,
left: -thickness / 2,
borderTop: `${thickness}px solid white`,
borderLeft: `${thickness}px solid white`,
},
'top-right': {
top: -thickness / 2,
right: -thickness / 2,
borderTop: `${thickness}px solid white`,
borderRight: `${thickness}px solid white`,
},
'bottom-left': {
bottom: -thickness / 2,
left: -thickness / 2,
borderBottom: `${thickness}px solid white`,
borderLeft: `${thickness}px solid white`,
},
'bottom-right': {
bottom: -thickness / 2,
right: -thickness / 2,
borderBottom: `${thickness}px solid white`,
borderRight: `${thickness}px solid white`,
},
};
return (
<Box
sx={{
position: 'absolute',
width: size,
height: size,
...positionStyles[position],
}}
/>
);
};
export default GuidanceOverlay;

View File

@@ -0,0 +1,32 @@
/**
* @ai-summary Barrel export for CameraCapture component
* @ai-context Exports main component, types, and sub-components
*/
export { CameraCapture, default } from './CameraCapture';
export { CameraViewfinder } from './CameraViewfinder';
export { GuidanceOverlay } from './GuidanceOverlay';
export { CropTool } from './CropTool';
export { FileInputFallback } from './FileInputFallback';
export { useCameraPermission } from './useCameraPermission';
export { useImageCrop } from './useImageCrop';
export type {
CameraCaptureProps,
CameraViewfinderProps,
GuidanceOverlayProps,
CropToolProps,
FileInputFallbackProps,
GuidanceType,
PermissionState,
CaptureState,
FacingMode,
CropArea,
GuidanceConfig,
} from './types';
export {
GUIDANCE_CONFIGS,
DEFAULT_ACCEPTED_FORMATS,
DEFAULT_MAX_FILE_SIZE,
} from './types';

View File

@@ -0,0 +1,131 @@
/**
* @ai-summary Type definitions for CameraCapture component
* @ai-context Defines props, state, and configuration types for camera capture functionality
*/
/** Guidance overlay type determining aspect ratio and visual hints */
export type GuidanceType = 'vin' | 'receipt' | 'document' | 'none';
/** Camera permission state */
export type PermissionState = 'prompt' | 'granted' | 'denied' | 'unavailable';
/** Camera capture workflow state */
export type CaptureState = 'idle' | 'viewfinder' | 'captured' | 'cropping';
/** Camera facing mode */
export type FacingMode = 'user' | 'environment';
/** Props for the main CameraCapture component */
export interface CameraCaptureProps {
/** Callback when capture is complete with original file and optional cropped file */
onCapture: (file: File, croppedFile?: File) => void;
/** Callback when user cancels capture */
onCancel: () => void;
/** Type of guidance overlay to display */
guidanceType?: GuidanceType;
/** Whether to show crop tool after capture */
allowCrop?: boolean;
/** Maximum file size in bytes (default 10MB) */
maxFileSize?: number;
/** Accepted MIME types */
acceptedFormats?: string[];
}
/** Props for CameraViewfinder component */
export interface CameraViewfinderProps {
/** Currently active media stream */
stream: MediaStream | null;
/** Guidance overlay type */
guidanceType: GuidanceType;
/** Callback when capture button is pressed */
onCapture: () => void;
/** Callback when user cancels */
onCancel: () => void;
/** Callback to switch camera */
onSwitchCamera: () => void;
/** Whether front/rear camera switch is available */
canSwitchCamera: boolean;
/** Current camera facing mode */
facingMode: FacingMode;
}
/** Props for GuidanceOverlay component */
export interface GuidanceOverlayProps {
/** Type of guidance overlay */
type: GuidanceType;
}
/** Props for CropTool component */
export interface CropToolProps {
/** Source image to crop */
imageSrc: string;
/** Whether to lock aspect ratio */
lockAspectRatio?: boolean;
/** Aspect ratio to lock to (width/height) */
aspectRatio?: number;
/** Callback when crop is confirmed */
onConfirm: (croppedBlob: Blob) => void;
/** Callback to reset crop */
onReset: () => void;
/** Callback to cancel and retake */
onRetake: () => void;
/** Callback when user skips cropping */
onSkip: () => void;
}
/** Props for FileInputFallback component */
export interface FileInputFallbackProps {
/** Callback when file is selected */
onFileSelect: (file: File) => void;
/** Callback when user cancels */
onCancel: () => void;
/** Accepted MIME types */
acceptedFormats: string[];
/** Maximum file size in bytes */
maxFileSize: number;
}
/** Crop area coordinates and dimensions */
export interface CropArea {
x: number;
y: number;
width: number;
height: number;
}
/** Guidance overlay aspect ratio configuration */
export interface GuidanceConfig {
aspectRatio: number;
label: string;
description: string;
}
/** Guidance configurations by type */
export const GUIDANCE_CONFIGS: Record<Exclude<GuidanceType, 'none'>, GuidanceConfig> = {
vin: {
aspectRatio: 6,
label: 'VIN',
description: 'Position VIN plate within the guide',
},
receipt: {
aspectRatio: 2 / 3,
label: 'Receipt',
description: 'Position receipt within the guide',
},
document: {
aspectRatio: 8.5 / 11,
label: 'Document',
description: 'Position document within the guide',
},
};
/** Default accepted formats */
export const DEFAULT_ACCEPTED_FORMATS = [
'image/jpeg',
'image/png',
'image/heic',
'image/heif',
];
/** Default max file size (10MB) */
export const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024;

View File

@@ -0,0 +1,200 @@
/**
* @ai-summary Hook for managing camera permission and media stream
* @ai-context Handles getUserMedia API, permission state, and camera switching
*/
import { useState, useCallback, useRef, useEffect } from 'react';
import type { PermissionState, FacingMode } from './types';
interface UseCameraPermissionOptions {
/** Initial facing mode preference */
initialFacingMode?: FacingMode;
}
interface UseCameraPermissionReturn {
/** Current permission state */
permissionState: PermissionState;
/** Active media stream */
stream: MediaStream | null;
/** Error message if any */
error: string | null;
/** Current camera facing mode */
facingMode: FacingMode;
/** Whether the device has multiple cameras */
hasMultipleCameras: boolean;
/** Request camera access */
requestPermission: () => Promise<void>;
/** Switch between front and rear camera */
switchCamera: () => Promise<void>;
/** Stop the media stream */
stopStream: () => void;
}
/**
* Hook for managing camera permissions and media streams
*/
export function useCameraPermission(
options: UseCameraPermissionOptions = {}
): UseCameraPermissionReturn {
const { initialFacingMode = 'environment' } = options;
const [permissionState, setPermissionState] = useState<PermissionState>('prompt');
const [stream, setStream] = useState<MediaStream | null>(null);
const [error, setError] = useState<string | null>(null);
const [facingMode, setFacingMode] = useState<FacingMode>(initialFacingMode);
const [hasMultipleCameras, setHasMultipleCameras] = useState(false);
const streamRef = useRef<MediaStream | null>(null);
// Check for multiple cameras on mount
useEffect(() => {
const checkCameras = async () => {
try {
if (!navigator.mediaDevices?.enumerateDevices) {
return;
}
const devices = await navigator.mediaDevices.enumerateDevices();
const videoInputs = devices.filter((d) => d.kind === 'videoinput');
setHasMultipleCameras(videoInputs.length > 1);
} catch {
// Silently fail - camera enumeration is not critical
}
};
checkCameras();
}, []);
// Clean up stream on unmount
useEffect(() => {
return () => {
if (streamRef.current) {
streamRef.current.getTracks().forEach((track) => track.stop());
}
};
}, []);
const stopStream = useCallback(() => {
if (streamRef.current) {
streamRef.current.getTracks().forEach((track) => track.stop());
streamRef.current = null;
setStream(null);
}
}, []);
const requestPermission = useCallback(async () => {
// Check if getUserMedia is available
if (!navigator.mediaDevices?.getUserMedia) {
setPermissionState('unavailable');
setError('Camera access is not supported in this browser');
return;
}
// Stop any existing stream
stopStream();
setError(null);
try {
const constraints: MediaStreamConstraints = {
video: {
facingMode: { ideal: facingMode },
width: { ideal: 1920 },
height: { ideal: 1080 },
},
audio: false,
};
const mediaStream = await navigator.mediaDevices.getUserMedia(constraints);
streamRef.current = mediaStream;
setStream(mediaStream);
setPermissionState('granted');
// Re-check for multiple cameras after permission granted
const devices = await navigator.mediaDevices.enumerateDevices();
const videoInputs = devices.filter((d) => d.kind === 'videoinput');
setHasMultipleCameras(videoInputs.length > 1);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
if (
err instanceof DOMException &&
(err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError')
) {
setPermissionState('denied');
setError('Camera permission was denied. Please enable camera access in your browser settings.');
} else if (
err instanceof DOMException &&
(err.name === 'NotFoundError' || err.name === 'DevicesNotFoundError')
) {
setPermissionState('unavailable');
setError('No camera found on this device');
} else if (
err instanceof DOMException &&
(err.name === 'NotReadableError' || err.name === 'TrackStartError')
) {
setPermissionState('unavailable');
setError('Camera is already in use by another application');
} else {
setPermissionState('unavailable');
setError(`Failed to access camera: ${errorMessage}`);
}
}
}, [facingMode, stopStream]);
const switchCamera = useCallback(async () => {
const newFacingMode: FacingMode = facingMode === 'environment' ? 'user' : 'environment';
setFacingMode(newFacingMode);
// If we have an active stream, restart with new facing mode
if (stream) {
stopStream();
try {
const constraints: MediaStreamConstraints = {
video: {
facingMode: { exact: newFacingMode },
width: { ideal: 1920 },
height: { ideal: 1080 },
},
audio: false,
};
const mediaStream = await navigator.mediaDevices.getUserMedia(constraints);
streamRef.current = mediaStream;
setStream(mediaStream);
} catch {
// If exact fails, try ideal
try {
const constraints: MediaStreamConstraints = {
video: {
facingMode: { ideal: newFacingMode },
width: { ideal: 1920 },
height: { ideal: 1080 },
},
audio: false,
};
const mediaStream = await navigator.mediaDevices.getUserMedia(constraints);
streamRef.current = mediaStream;
setStream(mediaStream);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
setError(`Failed to switch camera: ${errorMessage}`);
// Revert facing mode
setFacingMode(facingMode);
}
}
}
}, [facingMode, stream, stopStream]);
return {
permissionState,
stream,
error,
facingMode,
hasMultipleCameras,
requestPermission,
switchCamera,
stopStream,
};
}
export default useCameraPermission;

View File

@@ -0,0 +1,333 @@
/**
* @ai-summary Hook for managing image cropping state and operations
* @ai-context Handles crop area manipulation, touch/mouse interactions, and crop execution
*/
import { useState, useCallback, useRef, useEffect } from 'react';
import type { CropArea } from './types';
interface UseImageCropOptions {
/** Initial crop area as percentage of image */
initialCrop?: CropArea;
/** Lock aspect ratio (width/height) */
aspectRatio?: number;
/** Minimum crop size as percentage */
minSize?: number;
}
interface UseImageCropReturn {
/** Current crop area */
cropArea: CropArea;
/** Whether user is actively dragging */
isDragging: boolean;
/** Set crop area */
setCropArea: (area: CropArea) => void;
/** Reset crop to initial/default */
resetCrop: () => void;
/** Execute crop and return cropped blob */
executeCrop: (imageSrc: string, mimeType?: string) => Promise<Blob>;
/** Handle drag start for crop handles */
handleDragStart: (handle: CropHandle, event: React.MouseEvent | React.TouchEvent) => void;
/** Handle move during drag */
handleMove: (event: MouseEvent | TouchEvent) => void;
/** Handle drag end */
handleDragEnd: () => void;
}
export type CropHandle =
| 'move'
| 'nw'
| 'n'
| 'ne'
| 'e'
| 'se'
| 's'
| 'sw'
| 'w';
/**
* Hook for managing image cropping
*/
export function useImageCrop(options: UseImageCropOptions = {}): UseImageCropReturn {
const { aspectRatio, minSize = 10 } = options;
const defaultCrop: CropArea = {
x: 10,
y: 10,
width: 80,
height: 80,
};
const initialCrop = options.initialCrop || defaultCrop;
// Apply aspect ratio to initial crop if needed
const getAspectRatioAdjustedCrop = useCallback(
(crop: CropArea): CropArea => {
if (!aspectRatio) return crop;
// Adjust height based on width and aspect ratio
const newHeight = crop.width / aspectRatio;
return {
...crop,
height: Math.min(newHeight, 100 - crop.y),
};
},
[aspectRatio]
);
const [cropArea, setCropAreaState] = useState<CropArea>(
getAspectRatioAdjustedCrop(initialCrop)
);
const [isDragging, setIsDragging] = useState(false);
const activeHandleRef = useRef<CropHandle | null>(null);
const startPosRef = useRef({ x: 0, y: 0 });
const startCropRef = useRef<CropArea>(cropArea);
const containerRef = useRef<{ width: number; height: number }>({ width: 100, height: 100 });
const setCropArea = useCallback(
(area: CropArea) => {
setCropAreaState(getAspectRatioAdjustedCrop(area));
},
[getAspectRatioAdjustedCrop]
);
const resetCrop = useCallback(() => {
setCropAreaState(getAspectRatioAdjustedCrop(initialCrop));
}, [initialCrop, getAspectRatioAdjustedCrop]);
const constrainCrop = useCallback(
(crop: CropArea): CropArea => {
let { x, y, width, height } = crop;
// Ensure minimum size
width = Math.max(width, minSize);
height = Math.max(height, minSize);
// Apply aspect ratio if set
if (aspectRatio) {
height = width / aspectRatio;
}
// Constrain to bounds
x = Math.max(0, Math.min(x, 100 - width));
y = Math.max(0, Math.min(y, 100 - height));
width = Math.min(width, 100 - x);
height = Math.min(height, 100 - y);
return { x, y, width, height };
},
[aspectRatio, minSize]
);
const handleDragStart = useCallback(
(handle: CropHandle, event: React.MouseEvent | React.TouchEvent) => {
event.preventDefault();
activeHandleRef.current = handle;
startCropRef.current = { ...cropArea };
setIsDragging(true);
const clientX = 'touches' in event ? event.touches[0].clientX : event.clientX;
const clientY = 'touches' in event ? event.touches[0].clientY : event.clientY;
startPosRef.current = { x: clientX, y: clientY };
// Get container dimensions from the event target's parent
const target = event.currentTarget as HTMLElement;
const container = target.closest('[data-crop-container]');
if (container) {
const rect = container.getBoundingClientRect();
containerRef.current = { width: rect.width, height: rect.height };
}
},
[cropArea]
);
const handleMove = useCallback(
(event: MouseEvent | TouchEvent) => {
if (!activeHandleRef.current || !isDragging) return;
const clientX = 'touches' in event ? event.touches[0].clientX : event.clientX;
const clientY = 'touches' in event ? event.touches[0].clientY : event.clientY;
// Calculate delta as percentage of container
const deltaX = ((clientX - startPosRef.current.x) / containerRef.current.width) * 100;
const deltaY = ((clientY - startPosRef.current.y) / containerRef.current.height) * 100;
const start = startCropRef.current;
let newCrop: CropArea = { ...start };
switch (activeHandleRef.current) {
case 'move':
newCrop = {
...start,
x: start.x + deltaX,
y: start.y + deltaY,
};
break;
case 'nw':
newCrop = {
x: start.x + deltaX,
y: start.y + deltaY,
width: start.width - deltaX,
height: start.height - deltaY,
};
break;
case 'n':
newCrop = {
...start,
y: start.y + deltaY,
height: start.height - deltaY,
};
break;
case 'ne':
newCrop = {
...start,
y: start.y + deltaY,
width: start.width + deltaX,
height: start.height - deltaY,
};
break;
case 'e':
newCrop = {
...start,
width: start.width + deltaX,
};
break;
case 'se':
newCrop = {
...start,
width: start.width + deltaX,
height: start.height + deltaY,
};
break;
case 's':
newCrop = {
...start,
height: start.height + deltaY,
};
break;
case 'sw':
newCrop = {
x: start.x + deltaX,
y: start.y,
width: start.width - deltaX,
height: start.height + deltaY,
};
break;
case 'w':
newCrop = {
x: start.x + deltaX,
y: start.y,
width: start.width - deltaX,
height: start.height,
};
break;
}
setCropAreaState(constrainCrop(newCrop));
},
[isDragging, constrainCrop]
);
const handleDragEnd = useCallback(() => {
activeHandleRef.current = null;
setIsDragging(false);
}, []);
// Add global event listeners for drag
useEffect(() => {
if (isDragging) {
window.addEventListener('mousemove', handleMove);
window.addEventListener('mouseup', handleDragEnd);
window.addEventListener('touchmove', handleMove, { passive: false });
window.addEventListener('touchend', handleDragEnd);
}
return () => {
window.removeEventListener('mousemove', handleMove);
window.removeEventListener('mouseup', handleDragEnd);
window.removeEventListener('touchmove', handleMove);
window.removeEventListener('touchend', handleDragEnd);
};
}, [isDragging, handleMove, handleDragEnd]);
const executeCrop = useCallback(
async (imageSrc: string, mimeType: string = 'image/jpeg'): Promise<Blob> => {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('Failed to get canvas context'));
return;
}
// Calculate actual pixel values from percentages
const cropX = (cropArea.x / 100) * img.width;
const cropY = (cropArea.y / 100) * img.height;
const cropWidth = (cropArea.width / 100) * img.width;
const cropHeight = (cropArea.height / 100) * img.height;
canvas.width = cropWidth;
canvas.height = cropHeight;
ctx.drawImage(
img,
cropX,
cropY,
cropWidth,
cropHeight,
0,
0,
cropWidth,
cropHeight
);
canvas.toBlob(
(blob) => {
if (blob) {
resolve(blob);
} else {
reject(new Error('Failed to create blob from canvas'));
}
},
mimeType,
0.92
);
};
img.onerror = () => {
reject(new Error('Failed to load image for cropping'));
};
img.src = imageSrc;
});
},
[cropArea]
);
return {
cropArea,
isDragging,
setCropArea,
resetCrop,
executeCrop,
handleDragStart,
handleMove,
handleDragEnd,
};
}
export default useImageCrop;