249 lines
9.2 KiB
TypeScript
249 lines
9.2 KiB
TypeScript
// src/components/FlyerCorrectionTool.tsx
|
|
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
|
import { XCircleIcon } from './icons/XCircleIcon';
|
|
import { ScissorsIcon } from './icons/ScissorsIcon';
|
|
import { RefreshCwIcon } from './icons/RefreshCwIcon';
|
|
import * as aiApiClient from '../services/aiApiClient';
|
|
import { notifyError, notifySuccess } from '../services/notificationService';
|
|
import { logger } from '../services/logger.client';
|
|
|
|
export interface FlyerCorrectionToolProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
imageUrl: string;
|
|
onDataExtracted: (type: 'store_name' | 'dates', value: string) => void;
|
|
}
|
|
|
|
type Rect = { x: number; y: number; width: number; height: number };
|
|
type ExtractionType = 'store_name' | 'dates';
|
|
|
|
export const FlyerCorrectionTool: React.FC<FlyerCorrectionToolProps> = ({
|
|
isOpen,
|
|
onClose,
|
|
imageUrl,
|
|
onDataExtracted,
|
|
}) => {
|
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
const imageRef = useRef<HTMLImageElement>(null);
|
|
const [isDrawing, setIsDrawing] = useState(false);
|
|
const [selectionRect, setSelectionRect] = useState<Rect | null>(null);
|
|
const [startPoint, setStartPoint] = useState<{ x: number; y: number } | null>(null);
|
|
const [isProcessing, setIsProcessing] = useState(false);
|
|
const [imageFile, setImageFile] = useState<File | null>(null);
|
|
|
|
// Fetch the image and store it as a File object for API submission
|
|
useEffect(() => {
|
|
if (isOpen && imageUrl) {
|
|
console.debug('[DEBUG] FlyerCorrectionTool: isOpen is true, fetching image URL:', imageUrl);
|
|
fetch(imageUrl)
|
|
.then((res) => res.blob())
|
|
.then((blob) => {
|
|
const file = new File([blob], 'flyer-image.jpg', { type: blob.type });
|
|
setImageFile(file);
|
|
console.debug('[DEBUG] FlyerCorrectionTool: Image fetched and stored as File object.');
|
|
})
|
|
.catch((err) => {
|
|
console.error('[DEBUG] FlyerCorrectionTool: Failed to fetch image.', { err });
|
|
logger.error('Failed to fetch image for correction tool', { error: err });
|
|
notifyError('Could not load the image for correction.');
|
|
});
|
|
}
|
|
}, [isOpen, imageUrl]);
|
|
|
|
const draw = useCallback(() => {
|
|
const canvas = canvasRef.current;
|
|
const image = imageRef.current;
|
|
if (!canvas || !image) return;
|
|
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) return;
|
|
|
|
// Set canvas size to match image display size
|
|
canvas.width = image.clientWidth;
|
|
canvas.height = image.clientHeight;
|
|
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
|
|
if (selectionRect) {
|
|
ctx.strokeStyle = '#f59e0b'; // amber-500
|
|
ctx.lineWidth = 2;
|
|
ctx.setLineDash([6, 3]);
|
|
ctx.strokeRect(selectionRect.x, selectionRect.y, selectionRect.width, selectionRect.height);
|
|
}
|
|
}, [selectionRect]);
|
|
|
|
useEffect(() => {
|
|
draw();
|
|
const handleResize = () => draw();
|
|
window.addEventListener('resize', handleResize);
|
|
return () => window.removeEventListener('resize', handleResize);
|
|
}, [draw]);
|
|
|
|
const getCanvasCoordinates = (
|
|
e: React.MouseEvent<HTMLCanvasElement>,
|
|
): { x: number; y: number } => {
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) return { x: 0, y: 0 };
|
|
const rect = canvas.getBoundingClientRect();
|
|
return {
|
|
x: e.clientX - rect.left,
|
|
y: e.clientY - rect.top,
|
|
};
|
|
};
|
|
|
|
const handleMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
|
setIsDrawing(true);
|
|
setStartPoint(getCanvasCoordinates(e));
|
|
setSelectionRect(null);
|
|
};
|
|
|
|
const handleMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
|
if (!isDrawing || !startPoint) return;
|
|
const currentPoint = getCanvasCoordinates(e);
|
|
const rect = {
|
|
x: Math.min(startPoint.x, currentPoint.x),
|
|
y: Math.min(startPoint.y, currentPoint.y),
|
|
width: Math.abs(startPoint.x - currentPoint.x),
|
|
height: Math.abs(startPoint.y - currentPoint.y),
|
|
};
|
|
setSelectionRect(rect);
|
|
};
|
|
|
|
const handleMouseUp = () => {
|
|
setIsDrawing(false);
|
|
setStartPoint(null);
|
|
console.debug('[DEBUG] FlyerCorrectionTool: Mouse Up - selection complete.', { selectionRect });
|
|
};
|
|
|
|
const handleRescan = async (type: ExtractionType) => {
|
|
console.debug(`[DEBUG] handleRescan triggered for type: ${type}`);
|
|
console.debug(
|
|
`[DEBUG] handleRescan state: selectionRect=${!!selectionRect}, imageRef=${!!imageRef.current}, imageFile=${!!imageFile}`,
|
|
);
|
|
|
|
if (!selectionRect || !imageRef.current || !imageFile) {
|
|
console.warn('[DEBUG] handleRescan: Guard failed. Missing prerequisites.');
|
|
if (!selectionRect) console.warn('[DEBUG] Reason: No selectionRect');
|
|
if (!imageRef.current) console.warn('[DEBUG] Reason: No imageRef');
|
|
if (!imageFile) console.warn('[DEBUG] Reason: No imageFile');
|
|
|
|
notifyError('Please select an area on the image first.');
|
|
return;
|
|
}
|
|
|
|
console.debug(`[DEBUG] handleRescan: Prerequisites met. Starting processing for "${type}".`);
|
|
setIsProcessing(true);
|
|
try {
|
|
// Scale selection coordinates to the original image dimensions
|
|
const image = imageRef.current;
|
|
const scaleX = image.naturalWidth / image.clientWidth;
|
|
const scaleY = image.naturalHeight / image.clientHeight;
|
|
|
|
const cropArea = {
|
|
x: selectionRect.x * scaleX,
|
|
y: selectionRect.y * scaleY,
|
|
width: selectionRect.width * scaleX,
|
|
height: selectionRect.height * scaleY,
|
|
};
|
|
console.debug('[DEBUG] handleRescan: Calculated scaled cropArea:', cropArea);
|
|
|
|
console.debug('[DEBUG] handleRescan: Awaiting aiApiClient.rescanImageArea...');
|
|
const response = await aiApiClient.rescanImageArea(imageFile, cropArea, type);
|
|
console.debug('[DEBUG] handleRescan: API call returned. Response ok:', response.ok);
|
|
if (!response.ok) {
|
|
const errorData = await response.json();
|
|
throw new Error(errorData.message || 'Failed to rescan area.');
|
|
}
|
|
|
|
const { text } = await response.json();
|
|
console.debug('[DEBUG] handleRescan: Successfully extracted text:', text);
|
|
notifySuccess(`Extracted: ${text}`);
|
|
onDataExtracted(type, text);
|
|
onClose(); // Close modal on success
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : 'An unknown error occurred.';
|
|
console.error('[DEBUG] handleRescan: Caught an error.', { error: err });
|
|
notifyError(msg);
|
|
logger.error('Error during rescan:', { error: err });
|
|
} finally {
|
|
console.debug('[DEBUG] handleRescan: Finished. Setting isProcessing=false.');
|
|
setIsProcessing(false);
|
|
}
|
|
};
|
|
|
|
if (!isOpen) return null;
|
|
|
|
console.debug('[DEBUG] FlyerCorrectionTool: Rendering with state:', {
|
|
isProcessing,
|
|
hasSelection: !!selectionRect,
|
|
});
|
|
return (
|
|
<div
|
|
className="fixed inset-0 bg-black bg-opacity-75 z-50 flex justify-center items-center p-4"
|
|
onClick={onClose}
|
|
>
|
|
<div
|
|
role="dialog"
|
|
className="relative bg-gray-800 rounded-lg shadow-xl w-full max-w-6xl h-[90vh] flex flex-col"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div className="flex justify-between items-center p-4 border-b border-gray-700">
|
|
<h2 className="text-lg font-semibold text-white flex items-center">
|
|
<ScissorsIcon className="w-6 h-6 mr-2" /> Flyer Correction Tool
|
|
</h2>
|
|
<button
|
|
onClick={onClose}
|
|
className="text-gray-400 hover:text-white"
|
|
aria-label="Close correction tool"
|
|
>
|
|
<XCircleIcon className="w-7 h-7" />
|
|
</button>
|
|
</div>
|
|
<div className="grow p-4 overflow-auto relative flex justify-center items-center">
|
|
<img
|
|
ref={imageRef}
|
|
src={imageUrl}
|
|
alt="Flyer for correction"
|
|
className="max-w-full max-h-full object-contain"
|
|
onLoad={draw}
|
|
/>
|
|
<canvas
|
|
ref={canvasRef}
|
|
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 cursor-crosshair"
|
|
onMouseDown={handleMouseDown}
|
|
onMouseMove={handleMouseMove}
|
|
onMouseUp={handleMouseUp}
|
|
onMouseLeave={handleMouseUp}
|
|
/>
|
|
</div>
|
|
|
|
<div className="p-4 border-t border-gray-700 flex items-center justify-center space-x-4">
|
|
{isProcessing ? (
|
|
<div className="flex items-center text-white">
|
|
<RefreshCwIcon className="w-5 h-5 mr-2 animate-spin" />
|
|
<span>Processing...</span>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<button
|
|
onClick={() => handleRescan('store_name')}
|
|
disabled={!selectionRect || isProcessing}
|
|
className="px-4 py-2 bg-blue-600 text-white rounded-md disabled:bg-gray-500 disabled:cursor-not-allowed hover:bg-blue-700 transition-colors"
|
|
>
|
|
Extract Store Name
|
|
</button>
|
|
<button
|
|
onClick={() => handleRescan('dates')}
|
|
disabled={!selectionRect || isProcessing}
|
|
className="px-4 py-2 bg-green-600 text-white rounded-md disabled:bg-gray-500 disabled:cursor-not-allowed hover:bg-green-700 transition-colors"
|
|
>
|
|
Extract Sale Dates
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|