Files
tfm_ainventory/frontend/components/ImageAdjustmentModal.tsx
2026-04-26 14:45:54 +03:00

326 lines
12 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);
// DON'T process image in frontend - let backend handle rotation
// Just send the rotation value, backend will apply it once
const { width, height } = imageDimensions;
console.log('[Decision] Sending rotation to backend for processing');
console.log('[Decision] NOT processing image in frontend (avoid double rotation)');
console.log('=== END DEBUG ===');
// Return rotation value ONLY - no imageBlob
// Backend will apply rotation to original image
onConfirm({
rotation,
cropBounds: { x: 0, y: 0, width, height }
// NO imageBlob - use original from extractedImageBlob
});
};
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-surface-container-lowest/80 flex items-center justify-center z-50 p-4">
<div className="bg-surface-container border border-border rounded-none shadow-none max-w-6xl w-full max-h-[95vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="p-4 border-b border-border flex items-center justify-between bg-surface-bright">
<h2 className="text-xl font-normal text-white">Rotate Image</h2>
<button
onClick={onCancel}
className="p-2 hover:bg-border rounded text-muted 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-surface-bright 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-border text-secondary hover:bg-surface-bright'
}`}
>
{ratio.label}
</button>
))}
</div>
</div> */}
{/* Reset */}
<button
onClick={handleReset}
className="w-full py-2 px-3 rounded text-sm font-normal bg-border text-secondary hover:bg-surface-bright transition flex items-center justify-center gap-2"
>
<RotateCw size={16} />
Reset
</button>
</div>
{/* Center: Canvas */}
<div className="flex-1 flex flex-col bg-surface-bright p-4 rounded border border-border 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-surface-container-lowest/50 rounded border border-outline 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-muted mt-2">Scroll to zoom | Drag to pan | Rotate with slider</p>
</div>
</div>
{/* Footer */}
<div className="p-4 border-t border-border bg-surface-bright 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="toggle-pill"
/>
Use this image
</label>
<div className="flex gap-2">
<button
onClick={onCancel}
className="px-4 py-2 rounded text-sm font-normal bg-border text-secondary hover:bg-surface-bright 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>
);
}