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 getUserMedia API
  • ✅ 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.