Files
tfm_ainventory/frontend/components/ImageAdjustmentModal.tsx
Daniel Bedeleanu 743f377357 fix: CRITICAL - prevent image clipping when rotating
When rotating images, the rotated corners extend beyond the original canvas
bounds, causing the image to be clipped and appear skewed.

Fixed by:
1. Calculate canvas size needed to fit rotated image
2. Use formula: newSize = sqrt((w*cos)^2 + (h*sin)^2)
3. Center image and rotate around center of new canvas
4. Update cropBounds to new canvas dimensions

Now when user rotates an image, the full rotated result is saved without
any clipping or skewing. The saved image dimensions will be larger than
the original to accommodate the rotation.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-22 14:19:20 +03:00

362 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;
}
const { width, height } = imageDimensions;
// Calculate canvas size needed to fit rotated image (prevent clipping)
const radians = (rotation * Math.PI) / 180;
const cos = Math.abs(Math.cos(radians));
const sin = Math.abs(Math.sin(radians));
const newWidth = Math.ceil(width * cos + height * sin);
const newHeight = Math.ceil(width * sin + height * cos);
processCanvas.width = newWidth;
processCanvas.height = newHeight;
console.log('[Processing] Original dimensions:', width, 'x', height);
console.log('[Processing] Canvas size for rotation:', newWidth, 'x', newHeight);
console.log('[Processing] Applying rotation:', rotation, '°');
// Apply rotation around center of canvas
ctx.save();
ctx.translate(newWidth / 2, newHeight / 2);
ctx.rotate(radians);
ctx.drawImage(imageRef.current, -width / 2, -height / 2);
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('[Result] Final canvas size:', newWidth, 'x', newHeight);
console.log('=== END DEBUG ===');
// Return with new canvas bounds and rotation
onConfirm({
rotation,
cropBounds: { x: 0, y: 0, width: newWidth, height: newHeight },
imageBlob: blob
});
} else {
console.log('[ERROR] Failed to convert canvas to blob');
onConfirm({ rotation, cropBounds: { x: 0, y: 0, width: newWidth, height: newHeight } });
}
}, '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>
);
}