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:
32
frontend/src/shared/CLAUDE.md
Normal file
32
frontend/src/shared/CLAUDE.md
Normal 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}
|
||||||
|
/>
|
||||||
|
```
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
377
frontend/src/shared/components/CameraCapture/CameraCapture.tsx
Normal file
377
frontend/src/shared/components/CameraCapture/CameraCapture.tsx
Normal 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;
|
||||||
@@ -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;
|
||||||
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;
|
||||||
@@ -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;
|
||||||
145
frontend/src/shared/components/CameraCapture/GuidanceOverlay.tsx
Normal file
145
frontend/src/shared/components/CameraCapture/GuidanceOverlay.tsx
Normal 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;
|
||||||
32
frontend/src/shared/components/CameraCapture/index.ts
Normal file
32
frontend/src/shared/components/CameraCapture/index.ts
Normal 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';
|
||||||
131
frontend/src/shared/components/CameraCapture/types.ts
Normal file
131
frontend/src/shared/components/CameraCapture/types.ts
Normal 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;
|
||||||
@@ -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;
|
||||||
333
frontend/src/shared/components/CameraCapture/useImageCrop.ts
Normal file
333
frontend/src/shared/components/CameraCapture/useImageCrop.ts
Normal 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;
|
||||||
Reference in New Issue
Block a user