fix: modal actually processes image with rotation/crop and fixes zoom

Critical fixes:
1. Modal now applies rotation and crop to image, returns processed blob
2. Frontend sends processed image to backend (not original)
3. Zoom slider min/max now calculated based on image size
4. Initial zoom shows entire image in canvas
5. Zoom constraints prevent over-zooming or under-zooming

User experience improved:
- Sees full item at start (auto-fit zoom)
- Can adjust rotation/crop smoothly
- Adjusted image is what gets saved (not original)
- Zoom slider works correctly for any image size

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-04-22 13:05:19 +03:00
parent c963f953d8
commit 7f6d121d4a
3 changed files with 62 additions and 17 deletions

View File

@@ -60,13 +60,12 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
setShowImageAdjustment(true);
};
const handleImageAdjustmentConfirm = async (adjustments: { rotation: number; cropBounds: { x: number; y: number; width: number; height: number } } | null) => {
const handleImageAdjustmentConfirm = async (adjustments: any) => {
if (!pendingItemData) return;
const { index } = pendingItemData;
if (adjustments && extractedItems[index]) {
// Override AI-detected values with user adjustments
const updatedItems = [...extractedItems];
updatedItems[index] = {
...updatedItems[index],
@@ -82,11 +81,27 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
user_adjusted: true
}
};
setExtractedItems(updatedItems);
// If modal processed and returned image blob, use that instead of original
if (adjustments.imageBlob) {
// Convert blob to base64 for extracted_image_bytes
const reader = new FileReader();
reader.onload = () => {
const base64 = (reader.result as string).split(',')[1];
updatedItems[index].extractedImageBlob = adjustments.imageBlob;
updatedItems[index]._base64ImageData = base64;
setExtractedItems(updatedItems);
hookConfirmSingleItem(index);
};
reader.readAsDataURL(adjustments.imageBlob);
} else {
setExtractedItems(updatedItems);
hookConfirmSingleItem(index);
}
} else {
hookConfirmSingleItem(index);
}
// Now call the hook's confirm with the updated data
hookConfirmSingleItem(index);
setShowImageAdjustment(false);
setAdjustingItemIndex(null);
setPendingItemData(null);

View File

@@ -5,13 +5,15 @@ import { X, RotateCw } from 'lucide-react';
interface ImageAdjustmentModalProps {
imageUrl: string;
onConfirm: (adjustedImage: { rotation: number; cropBounds: { x: number; y: number; width: number; height: number } } | null) => void;
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);
@@ -51,8 +53,9 @@ export function ImageAdjustmentModal({ imageUrl, onConfirm, onCancel }: ImageAdj
const canvasHeight = 600;
const zoomX = canvasWidth / img.width;
const zoomY = canvasHeight / img.height;
const fitZoom = Math.min(zoomX, zoomY, 1); // Don't zoom in, only out to fit
setZoom(fitZoom);
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]);
@@ -115,18 +118,45 @@ export function ImageAdjustmentModal({ imageUrl, onConfirm, onCancel }: ImageAdj
}
};
const handleConfirm = () => {
if (useImage && cropBounds) {
onConfirm({ rotation, cropBounds });
} else {
const handleConfirm = async () => {
if (!useImage || !cropBounds || !imageRef.current) {
onConfirm(null);
return;
}
// Process image: apply rotation and crop
const processCanvas = document.createElement('canvas');
const ctx = processCanvas.getContext('2d');
if (!ctx) return;
const { x, y, width, height } = cropBounds;
// Set canvas to crop dimensions
processCanvas.width = width;
processCanvas.height = height;
// Apply rotation around crop center and crop
ctx.save();
ctx.translate(width / 2, height / 2);
ctx.rotate((rotation * Math.PI) / 180);
ctx.translate(-imageDimensions.width / 2 + x, -imageDimensions.height / 2 + y);
ctx.drawImage(imageRef.current, 0, 0);
ctx.restore();
// Convert to blob
processCanvas.toBlob((blob) => {
if (blob) {
onConfirm({ rotation, cropBounds, imageBlob: blob });
} else {
onConfirm({ rotation, cropBounds });
}
}, '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(0.5, Math.min(5, prev * delta)));
setZoom(prev => Math.max(minZoom, Math.min(maxZoom, prev * delta)));
};
const getTouchDistance = (touches: React.TouchList) => {
@@ -154,7 +184,7 @@ export function ImageAdjustmentModal({ imageUrl, onConfirm, onCancel }: ImageAdj
const newDistance = getTouchDistance(e.touches);
if (lastTouchDistance > 0) {
const delta = newDistance / lastTouchDistance;
setZoom(prev => Math.max(0.5, Math.min(5, prev * delta)));
setZoom(prev => Math.max(minZoom, Math.min(maxZoom, prev * delta)));
setLastTouchDistance(newDistance);
}
} else if (e.touches.length === 1 && isPanning) {
@@ -213,8 +243,8 @@ export function ImageAdjustmentModal({ imageUrl, onConfirm, onCancel }: ImageAdj
</label>
<input
type="range"
min="0.5"
max="5"
min={minZoom}
max={maxZoom}
step="0.1"
value={zoom}
onChange={(e) => setZoom(Number(e.target.value))}

File diff suppressed because one or more lines are too long