Square Image Cropping Frontend Requirements
Square Image Cropping Frontend Requirements
Created: Mån 7 Jul 2025 11:19:15 CEST
Overview
This document specifies the technical frontend requirements for implementing square image cropping functionality within the Plings image capture and upload system. The feature provides users with the option to crop images to square format during camera capture and file upload workflows.
Current Implementation Analysis
Existing Component Architecture
Image Capture System:
- CameraCapture.tsx: Handles live camera preview and photo capture
- ImageUploadSection.tsx: Manages multi-image upload with drag-drop support
- useImageUpload.ts: Hook for Supabase Storage integration and backend registration
Current Capabilities:
- ✅ Live camera preview with
getUserMediaAPI - ✅ Canvas-based image capture from video stream
- ✅ Multi-image upload with preview grid
- ✅ Image reordering and main image designation
- ✅ Background upload with progress tracking
- ✅ Direct Supabase Storage integration
Missing Components:
- ❌ Image crop editing interface
- ❌ Square preview overlay for camera
- ❌ Batch cropping functionality
- ❌ Crop parameter persistence
- ❌ Multiple image version management
Technical Requirements
Core Libraries and Dependencies
Image Processing:
// Required npm packages
{
"react-image-crop": "^11.0.0", // Crop interface component
"canvas-crop": "^1.0.0", // Canvas-based cropping utilities
"react-use-gesture": "^9.1.3" // Touch/mouse gesture handling
}
// Browser APIs utilized
- HTML5 Canvas API for image manipulation
- File API for image processing
- Touch Events API for mobile gesture support
- ResizeObserver API for responsive crop areas
TypeScript Interfaces:
interface CropArea {
x: number; // X offset as percentage (0-1)
y: number; // Y offset as percentage (0-1)
width: number; // Width as percentage (0-1)
height: number; // Height as percentage (0-1)
aspect: number; // Aspect ratio (1 for square)
}
interface CropSettings {
enabled: boolean;
showGuide: boolean;
aspect: number;
quality: number; // Output quality (0.1-1.0)
}
interface ImageVersion {
id: string;
type: 'original' | 'cropped';
url: string;
file?: File;
cropArea?: CropArea;
dimensions: { width: number; height: number };
}
interface CropState {
currentImage: string | null;
cropAreas: Map<string, CropArea>;
cropSettings: CropSettings;
processing: boolean;
previewMode: boolean;
}
Component Architecture
New Components to Create:
src/components/objects/image-cropping/
├── ImageCropEditor.tsx # Main crop editing interface
├── CropPreviewOverlay.tsx # Square guide overlay for camera
├── BatchCropManager.tsx # Multi-image crop interface
├── CropControls.tsx # Crop adjustment controls
├── AspectRatioSelector.tsx # Aspect ratio selection (1:1, 4:3, etc.)
└── hooks/
├── useImageCrop.ts # Core cropping logic hook
├── useCropGestures.ts # Touch/mouse gesture handling
└── useCropPreview.ts # Live preview management
Modified Existing Components
CameraCapture.tsx Enhancements:
interface CameraCaptureProps {
onImageCapture: (file: File) => void;
disabled?: boolean;
cropSettings?: CropSettings; // NEW: Crop configuration
showSquareGuide?: boolean; // NEW: Toggle square overlay
}
// New state for crop functionality
const [cropMode, setCropMode] = useState(false);
const [capturedImageForCrop, setCapturedImageForCrop] = useState<string | null>(null);
const [cropArea, setCropArea] = useState<CropArea | null>(null);
// Modified capture flow
const capturePhoto = () => {
// Existing capture logic...
if (cropSettings?.enabled) {
setCapturedImageForCrop(imageUrl);
setCropMode(true);
} else {
// Direct file creation as before
onImageCapture(file);
}
};
ImageUploadSection.tsx Enhancements:
interface ImageUploadSectionProps {
images: File[];
onImageAdd: (files: File[]) => void;
onImageRemove: (index: number) => void;
onImageReorder?: (newOrder: File[]) => void;
disabled?: boolean;
maxImages?: number;
cropSettings?: CropSettings; // NEW: Crop configuration
onCropComplete?: (croppedImages: ImageVersion[]) => void; // NEW: Crop callback
}
// New state for crop management
const [cropQueue, setCropQueue] = useState<Map<string, CropArea>>(new Map());
const [showBatchCrop, setShowBatchCrop] = useState(false);
const [croppedVersions, setCroppedVersions] = useState<Map<string, File>>(new Map());
Core Cropping Hook Implementation
useImageCrop.ts:
interface UseImageCropProps {
initialAspect?: number;
quality?: number;
maxOutputSize?: number;
}
interface UseImageCropReturn {
cropImage: (file: File, cropArea: CropArea) => Promise<File>;
createSquareCrop: (file: File, centerX?: number, centerY?: number) => Promise<File>;
batchCrop: (files: File[], cropAreas: Map<string, CropArea>) => Promise<Map<string, File>>;
calculateOptimalCrop: (imageDimensions: { width: number; height: number }) => CropArea;
isProcessing: boolean;
error: string | null;
}
export const useImageCrop = ({
initialAspect = 1,
quality = 0.9,
maxOutputSize = 2048
}: UseImageCropProps = {}): UseImageCropReturn => {
const [isProcessing, setIsProcessing] = useState(false);
const [error, setError] = useState<string | null>(null);
const cropImage = useCallback(async (file: File, cropArea: CropArea): Promise<File> => {
setIsProcessing(true);
setError(null);
try {
// Create canvas and load image
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const img = new Image();
await new Promise((resolve, reject) => {
img.onload = resolve;
img.onerror = reject;
img.src = URL.createObjectURL(file);
});
// Calculate crop dimensions
const cropWidth = img.width * cropArea.width;
const cropHeight = img.height * cropArea.height;
const cropX = img.width * cropArea.x;
const cropY = img.height * cropArea.y;
// Set canvas size to maintain aspect ratio
const outputSize = Math.min(cropWidth, cropHeight, maxOutputSize);
canvas.width = outputSize;
canvas.height = outputSize;
// Draw cropped image
ctx?.drawImage(
img,
cropX, cropY, cropWidth, cropHeight,
0, 0, outputSize, outputSize
);
// Convert to blob
return new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) {
const timestamp = Date.now();
const croppedFile = new File(
[blob],
`cropped-${timestamp}-${file.name}`,
{ type: file.type }
);
resolve(croppedFile);
} else {
reject(new Error('Failed to create cropped image'));
}
}, file.type, quality);
});
} catch (err) {
setError(err instanceof Error ? err.message : 'Crop operation failed');
throw err;
} finally {
setIsProcessing(false);
}
}, [quality, maxOutputSize]);
const createSquareCrop = useCallback(async (
file: File,
centerX: number = 0.5,
centerY: number = 0.5
): Promise<File> => {
// Calculate optimal square crop area
const img = new Image();
await new Promise((resolve) => {
img.onload = resolve;
img.src = URL.createObjectURL(file);
});
const minDimension = Math.min(img.width, img.height);
const cropSize = minDimension / Math.max(img.width, img.height);
const cropArea: CropArea = {
x: centerX - (cropSize / 2),
y: centerY - (cropSize / 2),
width: cropSize,
height: cropSize,
aspect: 1
};
return cropImage(file, cropArea);
}, [cropImage]);
return {
cropImage,
createSquareCrop,
batchCrop: useCallback(async (files: File[], cropAreas: Map<string, CropArea>) => {
const results = new Map<string, File>();
for (const [fileId, file] of files.entries()) {
const cropArea = cropAreas.get(fileId);
if (cropArea) {
try {
const croppedFile = await cropImage(file, cropArea);
results.set(fileId, croppedFile);
} catch (err) {
console.error(`Failed to crop image ${fileId}:`, err);
}
}
}
return results;
}, [cropImage]),
calculateOptimalCrop: useCallback((dimensions) => {
const { width, height } = dimensions;
const minDimension = Math.min(width, height);
const size = minDimension / Math.max(width, height);
return {
x: (1 - size) / 2,
y: (1 - size) / 2,
width: size,
height: size,
aspect: 1
};
}, []),
isProcessing,
error
};
};
Image Crop Editor Component
ImageCropEditor.tsx:
interface ImageCropEditorProps {
image: File | string;
initialCrop?: CropArea;
aspect?: number;
onCropChange: (cropArea: CropArea) => void;
onCropComplete: (croppedFile: File) => void;
onCancel: () => void;
disabled?: boolean;
}
const ImageCropEditor: React.FC<ImageCropEditorProps> = ({
image,
initialCrop,
aspect = 1,
onCropChange,
onCropComplete,
onCancel,
disabled = false
}) => {
const [crop, setCrop] = useState<CropArea>(
initialCrop || {
x: 0.25,
y: 0.25,
width: 0.5,
height: 0.5,
aspect
}
);
const [imageLoaded, setImageLoaded] = useState(false);
const [imageUrl, setImageUrl] = useState<string>('');
const imageRef = useRef<HTMLImageElement>(null);
const { cropImage, isProcessing } = useImageCrop();
useEffect(() => {
if (typeof image === 'string') {
setImageUrl(image);
} else {
const url = URL.createObjectURL(image);
setImageUrl(url);
return () => URL.revokeObjectURL(url);
}
}, [image]);
const handleCropComplete = async () => {
if (!imageLoaded || isProcessing) return;
try {
const file = typeof image === 'string'
? await fetch(image).then(r => r.blob()).then(b => new File([b], 'image.jpg'))
: image;
const croppedFile = await cropImage(file, crop);
onCropComplete(croppedFile);
} catch (error) {
console.error('Crop completion failed:', error);
}
};
const handleCropChange = (newCrop: CropArea) => {
setCrop(newCrop);
onCropChange(newCrop);
};
return (
<div className="image-crop-editor">
<div className="crop-container relative">
<img
ref={imageRef}
src={imageUrl}
alt="Crop preview"
onLoad={() => setImageLoaded(true)}
className="max-w-full max-h-96 mx-auto"
/>
{imageLoaded && (
<CropInterface
crop={crop}
onChange={handleCropChange}
aspect={aspect}
disabled={disabled || isProcessing}
/>
)}
</div>
<div className="crop-controls mt-4 flex justify-center space-x-3">
<Button
variant="outline"
onClick={onCancel}
disabled={isProcessing}
>
Cancel
</Button>
<Button
onClick={handleCropComplete}
disabled={!imageLoaded || isProcessing}
>
{isProcessing ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Processing...
</>
) : (
<>
<Check className="h-4 w-4 mr-2" />
Apply Crop
</>
)}
</Button>
</div>
</div>
);
};
Camera Preview Overlay Component
CropPreviewOverlay.tsx:
interface CropPreviewOverlayProps {
show: boolean;
aspect?: number;
opacity?: number;
color?: string;
}
const CropPreviewOverlay: React.FC<CropPreviewOverlayProps> = ({
show,
aspect = 1,
opacity = 0.3,
color = 'white'
}) => {
if (!show) return null;
return (
<div className="crop-preview-overlay absolute inset-0 pointer-events-none">
<svg
className="w-full h-full"
viewBox="0 0 100 100"
preserveAspectRatio="xMidYMid meet"
>
{/* Crop guide rectangle */}
<rect
x="20"
y="20"
width="60"
height="60"
fill="none"
stroke={color}
strokeWidth="0.5"
strokeDasharray="2,2"
opacity={opacity}
/>
{/* Corner indicators */}
<g stroke={color} strokeWidth="1" opacity={opacity + 0.2}>
<path d="M20,20 L25,20 M20,20 L20,25" />
<path d="M80,20 L75,20 M80,20 L80,25" />
<path d="M20,80 L25,80 M20,80 L20,75" />
<path d="M80,80 L75,80 M80,80 L80,75" />
</g>
</svg>
</div>
);
};
Batch Crop Manager Component
BatchCropManager.tsx:
interface BatchCropManagerProps {
images: File[];
onBatchCropComplete: (croppedImages: Map<string, File>) => void;
onCancel: () => void;
isOpen: boolean;
}
const BatchCropManager: React.FC<BatchCropManagerProps> = ({
images,
onBatchCropComplete,
onCancel,
isOpen
}) => {
const [currentImageIndex, setCurrentImageIndex] = useState(0);
const [cropAreas, setCropAreas] = useState<Map<string, CropArea>>(new Map());
const [processedImages, setProcessedImages] = useState<Set<number>>(new Set());
const { batchCrop, isProcessing } = useImageCrop();
const currentImage = images[currentImageIndex];
const currentImageId = `image-${currentImageIndex}`;
const handleCropChange = (cropArea: CropArea) => {
setCropAreas(prev => new Map(prev).set(currentImageId, cropArea));
};
const handleNextImage = () => {
setProcessedImages(prev => new Set(prev).add(currentImageIndex));
if (currentImageIndex < images.length - 1) {
setCurrentImageIndex(prev => prev + 1);
} else {
// All images processed, apply crops
handleApplyAllCrops();
}
};
const handleApplyAllCrops = async () => {
try {
const fileMap = new Map(images.map((file, index) => [`image-${index}`, file]));
const croppedImages = await batchCrop(Array.from(fileMap.entries()), cropAreas);
onBatchCropComplete(croppedImages);
} catch (error) {
console.error('Batch crop failed:', error);
}
};
if (!isOpen) return null;
return (
<Dialog open={isOpen} onOpenChange={onCancel}>
<DialogContent className="max-w-4xl max-h-[90vh]">
<DialogHeader>
<DialogTitle>
Crop Images to Square ({currentImageIndex + 1}/{images.length})
</DialogTitle>
</DialogHeader>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Thumbnail Grid */}
<div className="space-y-2">
<Label>Images</Label>
<div className="grid grid-cols-2 gap-2">
{images.map((file, index) => (
<div
key={index}
className={`relative cursor-pointer border-2 rounded-lg overflow-hidden ${
index === currentImageIndex
? 'border-blue-500'
: processedImages.has(index)
? 'border-green-500'
: 'border-gray-200'
}`}
onClick={() => setCurrentImageIndex(index)}
>
<img
src={URL.createObjectURL(file)}
alt={`Image ${index + 1}`}
className="w-full h-16 object-cover"
/>
{processedImages.has(index) && (
<div className="absolute top-1 right-1">
<Check className="h-4 w-4 text-green-600" />
</div>
)}
</div>
))}
</div>
</div>
{/* Main Crop Editor */}
<div className="lg:col-span-2">
{currentImage && (
<ImageCropEditor
image={currentImage}
initialCrop={cropAreas.get(currentImageId)}
onCropChange={handleCropChange}
onCropComplete={() => handleNextImage()}
onCancel={onCancel}
disabled={isProcessing}
/>
)}
</div>
</div>
<div className="flex justify-between pt-4 border-t">
<div className="flex items-center space-x-2">
<Button
variant="outline"
onClick={() => setCurrentImageIndex(Math.max(0, currentImageIndex - 1))}
disabled={currentImageIndex === 0 || isProcessing}
>
Previous
</Button>
<span className="text-sm text-gray-600">
{currentImageIndex + 1} of {images.length}
</span>
<Button
variant="outline"
onClick={() => setCurrentImageIndex(Math.min(images.length - 1, currentImageIndex + 1))}
disabled={currentImageIndex === images.length - 1 || isProcessing}
>
Next
</Button>
</div>
<div className="flex space-x-3">
<Button variant="outline" onClick={onCancel} disabled={isProcessing}>
Cancel
</Button>
<Button
onClick={handleApplyAllCrops}
disabled={isProcessing || cropAreas.size === 0}
>
{isProcessing ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Processing...
</>
) : (
<>
<Scissors className="h-4 w-4 mr-2" />
Apply All Crops
</>
)}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
};
Integration Requirements
Modified CreateObjectModal Integration
Enhanced CreateObjectModal.tsx:
// Add crop settings to modal state
const [cropSettings, setCropSettings] = useState<CropSettings>({
enabled: false,
showGuide: false,
aspect: 1,
quality: 0.9
});
// Pass crop settings to image upload section
<ImageUploadSection
images={selectedImages}
onImageAdd={handleImageAdd}
onImageRemove={handleImageRemove}
onImageReorder={handleImageReorder}
disabled={isProcessing}
maxImages={5}
cropSettings={cropSettings} // NEW
onCropComplete={handleCroppedImagesUpdate} // NEW
/>
// Handle cropped images
const handleCroppedImagesUpdate = (croppedImages: ImageVersion[]) => {
// Update selectedImages with cropped versions
const updatedImages = croppedImages.map(version =>
version.type === 'cropped' ? version.file! : selectedImages.find(/* match original */)
);
setSelectedImages(updatedImages);
};
Settings Integration
User Preferences for Cropping:
interface UserCropPreferences {
enableByDefault: boolean;
showCameraGuide: boolean;
defaultAspectRatio: number;
autoApplySquareCrop: boolean;
cropQuality: number;
}
// Store in localStorage or user settings
const DEFAULT_CROP_PREFERENCES: UserCropPreferences = {
enableByDefault: false,
showCameraGuide: true,
defaultAspectRatio: 1,
autoApplySquareCrop: false,
cropQuality: 0.9
};
Backend Integration Requirements
Enhanced Image Registration:
// Modified useImageUpload hook to handle cropped versions
interface ImageRegistrationPayload {
public_url: string;
storage_path: string;
is_cropped?: boolean;
original_image_id?: string;
crop_parameters?: CropArea;
}
// Register both original and cropped versions
const registerCroppedImage = async (objectId: string, originalFile: File, croppedFile: File, cropArea: CropArea) => {
// Upload original
const originalResult = await uploadAndRegister(objectId, originalFile, { is_cropped: false });
// Upload cropped version
const croppedResult = await uploadAndRegister(objectId, croppedFile, {
is_cropped: true,
original_image_id: originalResult.image_id,
crop_parameters: cropArea
});
return { original: originalResult, cropped: croppedResult };
};
User Interface Specifications
Visual Design Requirements
Crop Interface Styling:
.crop-container {
position: relative;
max-width: 100%;
max-height: 400px;
overflow: hidden;
border-radius: 8px;
background-color: #000;
}
.crop-overlay {
position: absolute;
border: 2px dashed rgba(255, 255, 255, 0.6);
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.5);
cursor: move;
}
.crop-handle {
position: absolute;
width: 12px;
height: 12px;
background: white;
border: 2px solid #3b82f6;
border-radius: 50%;
cursor: nw-resize;
}
.crop-handle.nw { top: -6px; left: -6px; }
.crop-handle.ne { top: -6px; right: -6px; cursor: ne-resize; }
.crop-handle.sw { bottom: -6px; left: -6px; cursor: sw-resize; }
.crop-handle.se { bottom: -6px; right: -6px; cursor: se-resize; }
Mobile Touch Optimizations:
@media (max-width: 768px) {
.crop-handle {
width: 16px;
height: 16px;
touch-action: none;
}
.crop-container {
touch-action: none;
user-select: none;
}
}
Accessibility Requirements
ARIA Labels and Descriptions:
// Crop editor accessibility
<div
role="img"
aria-label="Image crop editor"
aria-describedby="crop-instructions"
>
<p id="crop-instructions" className="sr-only">
Drag the crop area to select the portion of the image to keep.
Use the corner handles to resize the crop area.
</p>
<div
role="slider"
aria-label="Crop area position"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={crop.x * 100}
tabIndex={0}
onKeyDown={handleCropKeyboardNavigation}
/>
</div>
Keyboard Navigation Support:
const handleCropKeyboardNavigation = (e: KeyboardEvent) => {
const step = e.shiftKey ? 0.1 : 0.01; // Fine/coarse adjustment
switch (e.key) {
case 'ArrowLeft':
e.preventDefault();
setCrop(prev => ({ ...prev, x: Math.max(0, prev.x - step) }));
break;
case 'ArrowRight':
e.preventDefault();
setCrop(prev => ({ ...prev, x: Math.min(1 - prev.width, prev.x + step) }));
break;
// ... other arrow keys
}
};
Performance Optimizations
Image Processing Efficiency
Canvas Optimization:
// Reuse canvas instances to avoid garbage collection
const canvasPool = {
available: [] as HTMLCanvasElement[],
getCanvas(): HTMLCanvasElement {
return this.available.pop() || document.createElement('canvas');
},
returnCanvas(canvas: HTMLCanvasElement): void {
// Clear canvas and return to pool
const ctx = canvas.getContext('2d');
ctx?.clearRect(0, 0, canvas.width, canvas.height);
this.available.push(canvas);
}
};
// Optimize image loading with size constraints
const loadImageOptimized = async (file: File): Promise<HTMLImageElement> => {
// Create object URL with size limit
const maxSize = 2048;
const img = new Image();
return new Promise((resolve, reject) => {
img.onload = () => {
// Scale down if too large
if (img.width > maxSize || img.height > maxSize) {
const scale = Math.min(maxSize / img.width, maxSize / img.height);
const canvas = canvasPool.getCanvas();
canvas.width = img.width * scale;
canvas.height = img.height * scale;
const ctx = canvas.getContext('2d');
ctx?.drawImage(img, 0, 0, canvas.width, canvas.height);
canvas.toBlob((blob) => {
if (blob) {
const scaledImg = new Image();
scaledImg.onload = () => resolve(scaledImg);
scaledImg.src = URL.createObjectURL(blob);
canvasPool.returnCanvas(canvas);
}
});
} else {
resolve(img);
}
};
img.onerror = reject;
img.src = URL.createObjectURL(file);
});
};
Memory Management
Blob URL Cleanup:
// Hook for automatic URL cleanup
const useImageUrls = (images: File[]) => {
const [urls, setUrls] = useState<string[]>([]);
useEffect(() => {
const newUrls = images.map(file => URL.createObjectURL(file));
setUrls(newUrls);
return () => {
newUrls.forEach(url => URL.revokeObjectURL(url));
};
}, [images]);
return urls;
};
// Limit concurrent processing
const processImagesInBatches = async (
files: File[],
processor: (file: File) => Promise<File>,
batchSize: number = 3
) => {
const results: File[] = [];
for (let i = 0; i < files.length; i += batchSize) {
const batch = files.slice(i, i + batchSize);
const batchResults = await Promise.all(batch.map(processor));
results.push(...batchResults);
}
return results;
};
Testing Requirements
Unit Tests
Core Functionality Tests:
// useImageCrop hook tests
describe('useImageCrop', () => {
it('should crop image to square format', async () => {
const mockFile = new File(['mock'], 'test.jpg', { type: 'image/jpeg' });
const cropArea: CropArea = { x: 0.25, y: 0.25, width: 0.5, height: 0.5, aspect: 1 };
const { result } = renderHook(() => useImageCrop());
const croppedFile = await result.current.cropImage(mockFile, cropArea);
expect(croppedFile).toBeDefined();
expect(croppedFile.type).toBe('image/jpeg');
expect(croppedFile.name).toContain('cropped-');
});
it('should batch process multiple images', async () => {
const files = [mockFile1, mockFile2, mockFile3];
const cropAreas = new Map([
['file1', mockCropArea],
['file2', mockCropArea],
['file3', mockCropArea]
]);
const { result } = renderHook(() => useImageCrop());
const results = await result.current.batchCrop(files, cropAreas);
expect(results.size).toBe(3);
});
});
Integration Tests
Component Integration:
// ImageCropEditor integration tests
describe('ImageCropEditor', () => {
it('should handle crop area changes', () => {
const onCropChange = jest.fn();
render(
<ImageCropEditor
image={mockFile}
onCropChange={onCropChange}
onCropComplete={jest.fn()}
onCancel={jest.fn()}
/>
);
// Simulate drag interaction
fireEvent.mouseDown(screen.getByRole('slider'));
fireEvent.mouseMove(screen.getByRole('slider'), { clientX: 100, clientY: 100 });
fireEvent.mouseUp(screen.getByRole('slider'));
expect(onCropChange).toHaveBeenCalled();
});
});
Performance Tests
Image Processing Benchmarks:
// Performance testing for large images
describe('Image Processing Performance', () => {
it('should process 5MB image within 3 seconds', async () => {
const largeImage = generateMockImage(5000, 5000); // 5MB image
const startTime = Date.now();
const { result } = renderHook(() => useImageCrop());
await result.current.createSquareCrop(largeImage);
const endTime = Date.now();
expect(endTime - startTime).toBeLessThan(3000);
});
it('should handle batch processing without memory leaks', async () => {
const initialMemory = (performance as any).memory?.usedJSHeapSize;
const images = Array.from({ length: 10 }, () => generateMockImage(1000, 1000));
const { result } = renderHook(() => useImageCrop());
await result.current.batchCrop(images, mockCropAreas);
// Force garbage collection and check memory
if (global.gc) global.gc();
const finalMemory = (performance as any).memory?.usedJSHeapSize;
// Memory should not increase significantly
expect(finalMemory - initialMemory).toBeLessThan(50 * 1024 * 1024); // 50MB threshold
});
});
Deployment Considerations
Feature Flags
Progressive Rollout:
// Feature flag integration
const FEATURE_FLAGS = {
ENABLE_IMAGE_CROPPING: 'enable_image_cropping',
ENABLE_BATCH_CROPPING: 'enable_batch_cropping',
ENABLE_CAMERA_GUIDE: 'enable_camera_guide'
};
const useCropFeatures = () => {
const [features, setFeatures] = useState({
imageCropping: false,
batchCropping: false,
cameraGuide: false
});
useEffect(() => {
// Load feature flags from remote config or localStorage
const loadFeatures = async () => {
const config = await fetchFeatureFlags();
setFeatures({
imageCropping: config[FEATURE_FLAGS.ENABLE_IMAGE_CROPPING],
batchCropping: config[FEATURE_FLAGS.ENABLE_BATCH_CROPPING],
cameraGuide: config[FEATURE_FLAGS.ENABLE_CAMERA_GUIDE]
});
};
loadFeatures();
}, []);
return features;
};
Error Handling and Fallbacks
Graceful Degradation:
// Fallback for unsupported browsers
const isCropSupportAvailable = () => {
return (
'HTMLCanvasElement' in window &&
'File' in window &&
'FileReader' in window &&
'Blob' in window
);
};
// Error boundary for crop operations
class CropErrorBoundary extends React.Component {
state = { hasError: false, error: null };
static getDerivedStateFromError(error: Error) {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('Crop operation failed:', error, errorInfo);
// Log to error tracking service
}
render() {
if (this.state.hasError) {
return (
<div className="crop-error-fallback">
<p>Image cropping is temporarily unavailable.</p>
<Button onClick={() => this.setState({ hasError: false })}>
Continue without cropping
</Button>
</div>
);
}
return this.props.children;
}
}
Success Metrics and Analytics
Performance Metrics
Key Performance Indicators:
// Analytics tracking for crop feature usage
interface CropAnalytics {
cropOperationsPerSession: number;
averageCropTime: number;
cropSuccessRate: number;
batchCropUsage: number;
cameraGuideUsage: number;
userCropPreferences: Record<string, any>;
}
const trackCropOperation = (operation: string, duration: number, success: boolean) => {
analytics.track('image_crop_operation', {
operation,
duration_ms: duration,
success,
user_agent: navigator.userAgent,
timestamp: Date.now()
});
};
// Performance monitoring
const useCropPerformanceMonitoring = () => {
const trackCropPerformance = useCallback((
operation: string,
startTime: number,
endTime: number,
imageSize: number
) => {
const duration = endTime - startTime;
const megapixels = imageSize / (1024 * 1024);
// Log performance data
analytics.track('crop_performance', {
operation,
duration_ms: duration,
image_size_mb: megapixels,
performance_score: megapixels / (duration / 1000) // MB per second
});
}, []);
return { trackCropPerformance };
};
This technical requirements document provides comprehensive specifications for implementing square image cropping functionality within the Plings frontend application. It should be used in conjunction with the use case document and coordinated with backend API development for complete feature implementation.