Cropping UI was a placeholder and never fully implemented. Users couldn't actually select a crop region - it always used full image bounds. Simplified modal to focus on what works: - Rotation adjustment ✅ (fully working) - Zoom/pan for preview ✅ (fully working) - Removed aspect ratio controls (crop not functional) - Changed header to "Rotate Image" Image processing now: 1. Takes user's rotation adjustment 2. Applies rotation to full image 3. No cropping (uses full bounds) 4. Saves rotated image This ensures saved image matches the rotation user selected in modal. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
356 lines
13 KiB
TypeScript
356 lines
13 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect, useRef } from 'react';
|
|
import { X, RotateCw } from 'lucide-react';
|
|
|
|
interface ImageAdjustmentModalProps {
|
|
imageUrl: string;
|
|
onConfirm: (adjustedImage: { rotation: number; cropBounds: { x: number; y: number; width: number; height: number }; imageBlob?: Blob } | null) => void;
|
|
onCancel: () => void;
|
|
}
|
|
|
|
export function ImageAdjustmentModal({ imageUrl, onConfirm, onCancel }: ImageAdjustmentModalProps) {
|
|
const [rotation, setRotation] = useState(0);
|
|
const [zoom, setZoom] = useState(1);
|
|
const [minZoom, setMinZoom] = useState(0.1);
|
|
const [maxZoom, setMaxZoom] = useState(5);
|
|
const [panX, setPanX] = useState(0);
|
|
const [panY, setPanY] = useState(0);
|
|
const [useImage, setUseImage] = useState(true);
|
|
const [aspectRatio, setAspectRatio] = useState<number | null>(null);
|
|
const [cropBounds, setCropBounds] = useState<{ x: number; y: number; width: number; height: number } | null>(null);
|
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
const imageRef = useRef<HTMLImageElement | null>(null);
|
|
const [imageLoaded, setImageLoaded] = useState(false);
|
|
const [imageDimensions, setImageDimensions] = useState({ width: 0, height: 0 });
|
|
const [isDraggingCrop, setIsDraggingCrop] = useState<{ type: string; startX: number; startY: number } | null>(null);
|
|
const [isPanning, setIsPanning] = useState(false);
|
|
const [lastTouchDistance, setLastTouchDistance] = useState(0);
|
|
const [lastTouchX, setLastTouchX] = useState(0);
|
|
const [lastTouchY, setLastTouchY] = useState(0);
|
|
|
|
const aspectRatios = [
|
|
{ label: 'Free', value: null },
|
|
{ label: '16:9', value: 16 / 9 },
|
|
{ label: '4:3', value: 4 / 3 },
|
|
{ label: '1:1', value: 1 },
|
|
{ label: '9:16', value: 9 / 16 },
|
|
];
|
|
|
|
// Load image
|
|
useEffect(() => {
|
|
const img = new Image();
|
|
img.crossOrigin = 'anonymous';
|
|
img.onload = () => {
|
|
imageRef.current = img;
|
|
setImageLoaded(true);
|
|
setImageDimensions({ width: img.width, height: img.height });
|
|
// Initialize crop bounds to full image
|
|
setCropBounds({ x: 0, y: 0, width: img.width, height: img.height });
|
|
|
|
// Calculate initial zoom to fit image in canvas (800x600)
|
|
const canvasWidth = 800;
|
|
const canvasHeight = 600;
|
|
const zoomX = canvasWidth / img.width;
|
|
const zoomY = canvasHeight / img.height;
|
|
const fitZoom = Math.min(zoomX, zoomY); // Allow any zoom needed to fit
|
|
setMinZoom(fitZoom); // Min zoom shows entire image
|
|
setZoom(fitZoom); // Start at fit level
|
|
};
|
|
img.src = imageUrl;
|
|
}, [imageUrl]);
|
|
|
|
// Draw canvas with image, rotation, crop box
|
|
useEffect(() => {
|
|
if (!canvasRef.current || !imageRef.current || !imageLoaded || !cropBounds) return;
|
|
|
|
const canvas = canvasRef.current;
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) return;
|
|
|
|
// Canvas size
|
|
canvas.width = 800;
|
|
canvas.height = 600;
|
|
|
|
ctx.fillStyle = '#1e1e1e';
|
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
|
|
// Save state for rotation
|
|
ctx.save();
|
|
ctx.translate(canvas.width / 2, canvas.height / 2);
|
|
|
|
// Apply zoom
|
|
ctx.scale(zoom, zoom);
|
|
|
|
// Apply pan
|
|
ctx.translate(panX / zoom, panY / zoom);
|
|
|
|
// Apply rotation
|
|
ctx.rotate((rotation * Math.PI) / 180);
|
|
|
|
// Draw image centered
|
|
ctx.drawImage(imageRef.current, -imageDimensions.width / 2, -imageDimensions.height / 2);
|
|
|
|
ctx.restore();
|
|
|
|
// Draw crop box (in canvas coordinates, accounting for transformations)
|
|
// This is simplified - for full accuracy, we'd need to transform crop bounds
|
|
ctx.strokeStyle = '#00FF00';
|
|
ctx.lineWidth = 2;
|
|
ctx.setLineDash([5, 5]);
|
|
ctx.strokeRect(50, 50, 300, 200); // Placeholder - will improve
|
|
|
|
}, [imageLoaded, rotation, zoom, panX, panY, cropBounds]);
|
|
|
|
const handleReset = () => {
|
|
setRotation(0);
|
|
setPanX(0);
|
|
setPanY(0);
|
|
if (imageRef.current) {
|
|
setCropBounds({ x: 0, y: 0, width: imageRef.current.width, height: imageRef.current.height });
|
|
// Reset to initial fit zoom
|
|
const canvasWidth = 800;
|
|
const canvasHeight = 600;
|
|
const zoomX = canvasWidth / imageRef.current.width;
|
|
const zoomY = canvasHeight / imageRef.current.height;
|
|
const fitZoom = Math.min(zoomX, zoomY, 1);
|
|
setZoom(fitZoom);
|
|
}
|
|
};
|
|
|
|
const handleConfirm = async () => {
|
|
if (!useImage || !imageRef.current) {
|
|
console.log('[Modal] User clicked confirm - no image/useImage flag');
|
|
onConfirm(null);
|
|
return;
|
|
}
|
|
|
|
console.log('=== IMAGE ADJUSTMENT MODAL DEBUG ===');
|
|
console.log('[Original] Image dimensions:', imageDimensions);
|
|
console.log('[User] Rotation:', rotation, '°');
|
|
console.log('[User] Use image flag:', useImage);
|
|
|
|
// Process image: apply rotation ONLY (no crop)
|
|
const processCanvas = document.createElement('canvas');
|
|
const ctx = processCanvas.getContext('2d');
|
|
if (!ctx) {
|
|
console.log('[ERROR] Could not get canvas context');
|
|
return;
|
|
}
|
|
|
|
// Use full image dimensions (no cropping)
|
|
const { width, height } = imageDimensions;
|
|
processCanvas.width = width;
|
|
processCanvas.height = height;
|
|
|
|
console.log('[Processing] Canvas dimensions:', width, 'x', height);
|
|
console.log('[Processing] Applying rotation:', rotation, '°');
|
|
|
|
// Apply rotation around center
|
|
ctx.save();
|
|
ctx.translate(width / 2, height / 2);
|
|
ctx.rotate((rotation * Math.PI) / 180);
|
|
ctx.translate(-width / 2, -height / 2);
|
|
|
|
console.log('[Processing] Drawing image with rotation applied');
|
|
ctx.drawImage(imageRef.current, 0, 0);
|
|
ctx.restore();
|
|
|
|
console.log('[Processing] Canvas draw complete');
|
|
|
|
// Convert to blob
|
|
processCanvas.toBlob((blob) => {
|
|
if (blob) {
|
|
console.log('[Result] Processed image blob size:', blob.size, 'bytes');
|
|
console.log('[Result] Blob type:', blob.type);
|
|
console.log('=== END DEBUG ===');
|
|
// Return with full image bounds (no crop) and rotation
|
|
onConfirm({
|
|
rotation,
|
|
cropBounds: { x: 0, y: 0, width, height },
|
|
imageBlob: blob
|
|
});
|
|
} else {
|
|
console.log('[ERROR] Failed to convert canvas to blob');
|
|
onConfirm({ rotation, cropBounds: { x: 0, y: 0, width, height } });
|
|
}
|
|
}, 'image/jpeg', 0.95);
|
|
};
|
|
|
|
const handleWheel = (e: React.WheelEvent<HTMLCanvasElement>) => {
|
|
e.preventDefault();
|
|
const delta = e.deltaY > 0 ? 0.9 : 1.1;
|
|
setZoom(prev => Math.max(minZoom, Math.min(maxZoom, prev * delta)));
|
|
};
|
|
|
|
const getTouchDistance = (touches: React.TouchList) => {
|
|
if (touches.length < 2) return 0;
|
|
const dx = touches[0].clientX - touches[1].clientX;
|
|
const dy = touches[0].clientY - touches[1].clientY;
|
|
return Math.sqrt(dx * dx + dy * dy);
|
|
};
|
|
|
|
const handleTouchStart = (e: React.TouchEvent<HTMLCanvasElement>) => {
|
|
if (e.touches.length === 2) {
|
|
setLastTouchDistance(getTouchDistance(e.touches));
|
|
} else if (e.touches.length === 1) {
|
|
setIsPanning(true);
|
|
setLastTouchX(e.touches[0].clientX);
|
|
setLastTouchY(e.touches[0].clientY);
|
|
}
|
|
};
|
|
|
|
const handleTouchMove = (e: React.TouchEvent<HTMLCanvasElement>) => {
|
|
e.preventDefault();
|
|
|
|
if (e.touches.length === 2) {
|
|
// Pinch zoom
|
|
const newDistance = getTouchDistance(e.touches);
|
|
if (lastTouchDistance > 0) {
|
|
const delta = newDistance / lastTouchDistance;
|
|
setZoom(prev => Math.max(minZoom, Math.min(maxZoom, prev * delta)));
|
|
setLastTouchDistance(newDistance);
|
|
}
|
|
} else if (e.touches.length === 1 && isPanning) {
|
|
// Single finger pan
|
|
const touch = e.touches[0];
|
|
const dx = touch.clientX - lastTouchX;
|
|
const dy = touch.clientY - lastTouchY;
|
|
setPanX(prev => prev + dx);
|
|
setPanY(prev => prev + dy);
|
|
setLastTouchX(touch.clientX);
|
|
setLastTouchY(touch.clientY);
|
|
}
|
|
};
|
|
|
|
const handleTouchEnd = () => {
|
|
setIsPanning(false);
|
|
setLastTouchDistance(0);
|
|
};
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-black/80 flex items-center justify-center z-50 p-4">
|
|
<div className="bg-slate-900 border border-slate-700 rounded-lg shadow-2xl max-w-6xl w-full max-h-[95vh] overflow-hidden flex flex-col">
|
|
{/* Header */}
|
|
<div className="p-4 border-b border-slate-700 flex items-center justify-between bg-slate-800">
|
|
<h2 className="text-xl font-normal text-white">Rotate Image</h2>
|
|
<button
|
|
onClick={onCancel}
|
|
className="p-2 hover:bg-slate-700 rounded text-gray-400 hover:text-white transition"
|
|
>
|
|
<X size={20} />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex gap-4 p-4 flex-1 overflow-hidden">
|
|
{/* Left: Controls */}
|
|
<div className="w-56 bg-slate-800 p-4 rounded space-y-4 flex-shrink-0 overflow-y-auto">
|
|
{/* Rotation */}
|
|
<div>
|
|
<label className="block text-sm font-normal text-white mb-2">
|
|
Rotation: <span className="text-yellow-400">{rotation}°</span>
|
|
</label>
|
|
<input
|
|
type="range"
|
|
min="-180"
|
|
max="180"
|
|
value={rotation}
|
|
onChange={(e) => setRotation(Number(e.target.value))}
|
|
className="w-full"
|
|
/>
|
|
</div>
|
|
|
|
{/* Zoom */}
|
|
<div>
|
|
<label className="block text-sm font-normal text-white mb-2">
|
|
Zoom: <span className="text-yellow-400">{zoom.toFixed(2)}x</span>
|
|
</label>
|
|
<input
|
|
type="range"
|
|
min={minZoom}
|
|
max={maxZoom}
|
|
step="0.1"
|
|
value={zoom}
|
|
onChange={(e) => setZoom(Number(e.target.value))}
|
|
className="w-full"
|
|
/>
|
|
</div>
|
|
|
|
{/* Aspect Ratio - DISABLED (cropping not implemented) */}
|
|
{/* <div>
|
|
<label className="block text-sm font-normal text-white mb-2">Aspect Ratio</label>
|
|
<div className="space-y-2">
|
|
{aspectRatios.map((ratio) => (
|
|
<button
|
|
key={ratio.label}
|
|
onClick={() => setAspectRatio(ratio.value)}
|
|
className={`w-full py-2 px-3 rounded text-sm font-normal transition ${
|
|
aspectRatio === ratio.value
|
|
? 'bg-yellow-600 text-white'
|
|
: 'bg-slate-700 text-gray-300 hover:bg-slate-600'
|
|
}`}
|
|
>
|
|
{ratio.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div> */}
|
|
|
|
{/* Reset */}
|
|
<button
|
|
onClick={handleReset}
|
|
className="w-full py-2 px-3 rounded text-sm font-normal bg-slate-700 text-gray-300 hover:bg-slate-600 transition flex items-center justify-center gap-2"
|
|
>
|
|
<RotateCw size={16} />
|
|
Reset
|
|
</button>
|
|
</div>
|
|
|
|
{/* Center: Canvas */}
|
|
<div className="flex-1 flex flex-col bg-slate-800 p-4 rounded border border-slate-700 overflow-hidden">
|
|
<h3 className="text-sm font-normal text-white mb-2">Image Preview</h3>
|
|
<div className="flex-1 flex items-center justify-center bg-black/50 rounded border border-slate-600 overflow-hidden">
|
|
<canvas
|
|
ref={canvasRef}
|
|
onWheel={handleWheel}
|
|
onTouchStart={handleTouchStart}
|
|
onTouchMove={handleTouchMove}
|
|
onTouchEnd={handleTouchEnd}
|
|
className="max-w-full max-h-full cursor-grab active:cursor-grabbing touch-none"
|
|
/>
|
|
</div>
|
|
<p className="text-xs text-gray-400 mt-2">Scroll to zoom | Drag to pan | Rotate with slider</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="p-4 border-t border-slate-700 bg-slate-800 flex items-center justify-between">
|
|
<label className="flex items-center gap-2 text-sm font-normal text-white">
|
|
<input
|
|
type="checkbox"
|
|
checked={useImage}
|
|
onChange={(e) => setUseImage(e.target.checked)}
|
|
className="w-4 h-4"
|
|
/>
|
|
Use this image
|
|
</label>
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={onCancel}
|
|
className="px-4 py-2 rounded text-sm font-normal bg-slate-700 text-gray-300 hover:bg-slate-600 transition"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={handleConfirm}
|
|
className="px-4 py-2 rounded text-sm font-normal bg-yellow-600 text-white hover:bg-yellow-700 transition"
|
|
>
|
|
Confirm
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|