- useCropHandles.ts: Hook managing crop bounds state, drag operations, and constraints - 8 draggable handles (4 corners + 4 edges) - Real-time crop bounds calculation during drag - Constrained within image bounds (no dragging outside) - Minimum crop size enforcement (100x100px) - Support for mobile (touch) and desktop (mouse) events - Bounds validation on initialization - ManualCropUI.tsx: Interactive crop preview component - Responsive image display with calculated scaling - Semi-transparent overlay outside crop box with visible bounding box - 8 draggable handles with visual feedback (highlight/scale on hover) - 'Use Full Photo' button to clear crop and show full image - Real-time onCropChange callbacks to parent - Error handling for failed image loads - Touch and mouse event support (desktop + mobile) - TypeScript strict mode compliant - Tests: 26 comprehensive test cases - Hook tests (26 passing): initialization, setCrop, resetCrop, drag operations (corners, edges), constraints, endDrag, edge cases - Component tests (26 passing): rendering, handles, overlay, callbacks, button, error handling, dimensions, touch/mouse support, responsive behavior, size enforcement, bounds display All 364 tests passing (13 test files, zero regressions) Minimum crop size: 100x100px Handle visual feedback on hover/drag TypeScript strict mode: ✓
317 lines
9.9 KiB
TypeScript
317 lines
9.9 KiB
TypeScript
import React, { useRef, useEffect, useState } from 'react';
|
|
import { X } from 'lucide-react';
|
|
import { useCropHandles, type CropBounds, type HandleType } from '@/hooks/useCropHandles';
|
|
|
|
interface ManualCropUIProps {
|
|
imageUrl: string;
|
|
onCropChange: (bounds: CropBounds | null) => void;
|
|
imageDimensions?: { width: number; height: number };
|
|
initialCrop?: CropBounds;
|
|
}
|
|
|
|
const HANDLE_SIZE = 12;
|
|
const HANDLE_TYPES: HandleType[] = [
|
|
'top-left',
|
|
'top',
|
|
'top-right',
|
|
'right',
|
|
'bottom-right',
|
|
'bottom',
|
|
'bottom-left',
|
|
'left',
|
|
];
|
|
|
|
export default function ManualCropUI({
|
|
imageUrl,
|
|
onCropChange,
|
|
imageDimensions,
|
|
initialCrop,
|
|
}: ManualCropUIProps) {
|
|
const imageRef = useRef<HTMLImageElement>(null);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const [displayDimensions, setDisplayDimensions] = useState<{ width: number; height: number } | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const actualDimensions = imageDimensions || displayDimensions;
|
|
|
|
const { crop, setCrop, startDrag, moveDrag, endDrag, resetCrop, isDragging } = useCropHandles(
|
|
actualDimensions
|
|
? {
|
|
imageDimensions: actualDimensions,
|
|
initialCrop,
|
|
minSize: 100,
|
|
}
|
|
: { imageDimensions: { width: 1, height: 1 }, initialCrop, minSize: 100 }
|
|
);
|
|
|
|
// Measure image dimensions on load
|
|
const handleImageLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {
|
|
const img = e.currentTarget;
|
|
const width = img.naturalWidth || img.width;
|
|
const height = img.naturalHeight || img.height;
|
|
|
|
if (width && height) {
|
|
setDisplayDimensions({ width, height });
|
|
setError(null);
|
|
}
|
|
};
|
|
|
|
const handleImageError = () => {
|
|
setError('Failed to load image');
|
|
};
|
|
|
|
// Emit crop changes to parent
|
|
useEffect(() => {
|
|
onCropChange(crop);
|
|
}, [crop, onCropChange]);
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="flex items-center justify-center p-8 bg-slate-900 rounded-lg">
|
|
<div className="text-center">
|
|
<p className="text-rose-500">{error}</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// If no dimensions yet, show minimal loading state with hidden image
|
|
if (!actualDimensions) {
|
|
return (
|
|
<div className="flex flex-col gap-4">
|
|
<div className="relative bg-slate-900 rounded-lg overflow-hidden border border-slate-800">
|
|
<img
|
|
ref={imageRef}
|
|
src={imageUrl}
|
|
alt="Photo preview"
|
|
onLoad={handleImageLoad}
|
|
onError={handleImageError}
|
|
className="w-full h-full object-contain"
|
|
style={{ visibility: 'hidden' }}
|
|
/>
|
|
</div>
|
|
<div className="text-sm text-slate-400 text-center">Loading image...</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const containerWidth = containerRef.current?.clientWidth || 400;
|
|
const scale = containerWidth / actualDimensions.width;
|
|
|
|
const displayWidth = actualDimensions.width * scale;
|
|
const displayHeight = actualDimensions.height * scale;
|
|
|
|
const getHandlePosition = (
|
|
handleType: HandleType
|
|
): { left: string; top: string; transform: string } => {
|
|
if (!crop) {
|
|
return { left: '0', top: '0', transform: 'translate(0, 0)' };
|
|
}
|
|
|
|
const x = crop.x * scale;
|
|
const y = crop.y * scale;
|
|
const w = crop.width * scale;
|
|
const h = crop.height * scale;
|
|
|
|
const offsets = {
|
|
'top-left': { left: x, top: y },
|
|
top: { left: x + w / 2, top: y },
|
|
'top-right': { left: x + w, top: y },
|
|
right: { left: x + w, top: y + h / 2 },
|
|
'bottom-right': { left: x + w, top: y + h },
|
|
bottom: { left: x + w / 2, top: y + h },
|
|
'bottom-left': { left: x, top: y + h },
|
|
left: { left: x, top: y + h / 2 },
|
|
};
|
|
|
|
const pos = offsets[handleType];
|
|
return {
|
|
left: `${pos.left}px`,
|
|
top: `${pos.top}px`,
|
|
transform: 'translate(-50%, -50%)',
|
|
};
|
|
};
|
|
|
|
const handleMouseDown = (handleType: HandleType) => (e: React.MouseEvent) => {
|
|
e.preventDefault();
|
|
if (!containerRef.current || !crop) return;
|
|
|
|
const rect = containerRef.current.getBoundingClientRect();
|
|
const startX = (e.clientX - rect.left) / scale;
|
|
const startY = (e.clientY - rect.top) / scale;
|
|
|
|
startDrag(handleType, startX, startY);
|
|
};
|
|
|
|
const handleTouchStart = (handleType: HandleType) => (e: React.TouchEvent) => {
|
|
e.preventDefault();
|
|
if (!containerRef.current || !crop || e.touches.length === 0) return;
|
|
|
|
const rect = containerRef.current.getBoundingClientRect();
|
|
const touch = e.touches[0];
|
|
const startX = (touch.clientX - rect.left) / scale;
|
|
const startY = (touch.clientY - rect.top) / scale;
|
|
|
|
startDrag(handleType, startX, startY);
|
|
};
|
|
|
|
// Global mouse/touch move and end listeners
|
|
useEffect(() => {
|
|
if (!isDragging) return;
|
|
|
|
const handleMouseMove = (e: MouseEvent) => {
|
|
if (!containerRef.current) return;
|
|
const rect = containerRef.current.getBoundingClientRect();
|
|
const currentX = (e.clientX - rect.left) / scale;
|
|
const currentY = (e.clientY - rect.top) / scale;
|
|
moveDrag(currentX, currentY);
|
|
};
|
|
|
|
const handleTouchMove = (e: TouchEvent) => {
|
|
if (!containerRef.current || e.touches.length === 0) return;
|
|
const rect = containerRef.current.getBoundingClientRect();
|
|
const touch = e.touches[0];
|
|
const currentX = (touch.clientX - rect.left) / scale;
|
|
const currentY = (touch.clientY - rect.top) / scale;
|
|
moveDrag(currentX, currentY);
|
|
};
|
|
|
|
const handleEnd = () => {
|
|
endDrag();
|
|
};
|
|
|
|
document.addEventListener('mousemove', handleMouseMove);
|
|
document.addEventListener('mouseup', handleEnd);
|
|
document.addEventListener('touchmove', handleTouchMove, { passive: false });
|
|
document.addEventListener('touchend', handleEnd);
|
|
|
|
return () => {
|
|
document.removeEventListener('mousemove', handleMouseMove);
|
|
document.removeEventListener('mouseup', handleEnd);
|
|
document.removeEventListener('touchmove', handleTouchMove);
|
|
document.removeEventListener('touchend', handleEnd);
|
|
};
|
|
}, [isDragging, scale, moveDrag, endDrag]);
|
|
|
|
return (
|
|
<div className="flex flex-col gap-4">
|
|
{/* Image container with crop preview */}
|
|
<div
|
|
ref={containerRef}
|
|
className="relative bg-slate-900 rounded-lg overflow-hidden border border-slate-800"
|
|
style={{
|
|
aspectRatio: `${actualDimensions.width} / ${actualDimensions.height}`,
|
|
maxWidth: '100%',
|
|
}}
|
|
>
|
|
{/* Image */}
|
|
<img
|
|
ref={imageRef}
|
|
src={imageUrl}
|
|
alt="Photo preview"
|
|
onLoad={handleImageLoad}
|
|
onError={handleImageError}
|
|
className="w-full h-full object-contain"
|
|
/>
|
|
|
|
{/* Semi-transparent overlay outside crop box */}
|
|
{crop && (
|
|
<div className="absolute inset-0 pointer-events-none">
|
|
{/* Top overlay */}
|
|
<div
|
|
className="absolute left-0 right-0 bg-black/40"
|
|
style={{
|
|
top: 0,
|
|
height: `${crop.y * scale}px`,
|
|
}}
|
|
/>
|
|
{/* Bottom overlay */}
|
|
<div
|
|
className="absolute left-0 right-0 bg-black/40"
|
|
style={{
|
|
top: `${(crop.y + crop.height) * scale}px`,
|
|
bottom: 0,
|
|
}}
|
|
/>
|
|
{/* Left overlay */}
|
|
<div
|
|
className="absolute top-0 bottom-0 bg-black/40"
|
|
style={{
|
|
left: 0,
|
|
width: `${crop.x * scale}px`,
|
|
top: `${crop.y * scale}px`,
|
|
height: `${crop.height * scale}px`,
|
|
}}
|
|
/>
|
|
{/* Right overlay */}
|
|
<div
|
|
className="absolute top-0 bottom-0 bg-black/40"
|
|
style={{
|
|
left: `${(crop.x + crop.width) * scale}px`,
|
|
right: 0,
|
|
top: `${crop.y * scale}px`,
|
|
height: `${crop.height * scale}px`,
|
|
}}
|
|
/>
|
|
|
|
{/* Crop bounding box */}
|
|
<div
|
|
className="absolute border-2 border-cyan-400"
|
|
style={{
|
|
left: `${crop.x * scale}px`,
|
|
top: `${crop.y * scale}px`,
|
|
width: `${crop.width * scale}px`,
|
|
height: `${crop.height * scale}px`,
|
|
}}
|
|
/>
|
|
|
|
{/* Handles */}
|
|
{HANDLE_TYPES.map((handleType) => (
|
|
<button
|
|
key={handleType}
|
|
onMouseDown={handleMouseDown(handleType)}
|
|
onTouchStart={handleTouchStart(handleType)}
|
|
className={`absolute w-${HANDLE_SIZE} h-${HANDLE_SIZE} bg-cyan-400 rounded-full border-2 border-white shadow-lg hover:scale-125 transition-transform cursor-grab active:cursor-grabbing ${
|
|
isDragging ? 'scale-125' : ''
|
|
}`}
|
|
style={{
|
|
...getHandlePosition(handleType),
|
|
width: `${HANDLE_SIZE}px`,
|
|
height: `${HANDLE_SIZE}px`,
|
|
}}
|
|
aria-label={`Drag ${handleType} handle`}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Controls */}
|
|
<div className="flex gap-2">
|
|
{crop && (
|
|
<button
|
|
onClick={() => resetCrop()}
|
|
className="flex items-center justify-center gap-2 flex-1 px-4 py-2.5 bg-slate-700 text-white rounded-lg font-normal text-base transition-colors hover:bg-slate-600"
|
|
>
|
|
<X className="w-5 h-5" />
|
|
<span>Use Full Photo</span>
|
|
</button>
|
|
)}
|
|
{!crop && (
|
|
<div className="text-sm text-slate-400 text-center flex-1 py-2.5">
|
|
Drag handles to adjust crop area
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Crop bounds display (debug) */}
|
|
{crop && (
|
|
<div className="text-xs text-slate-500 text-center">
|
|
Crop: {Math.round(crop.x)}, {Math.round(crop.y)} | Size: {Math.round(crop.width)} x{' '}
|
|
{Math.round(crop.height)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|