Files
tfm_ainventory/frontend/components/Scanner.tsx
Daniel Bedeleanu 3e60bb1707 perf(frontend): lazy-load tesseract.js OCR library
Replace static import of tesseract.js (500KB) with dynamic import in Scanner
component. Library now only loads when OCR recognition is actually performed,
not on initial page load.

Performance Impact:
- Removes 500KB from initial bundle
- Library only loads when user activates OCR mode
- Preload strategy in page.tsx still works (loads on-demand if scanner opened)
- No functional change; same behavior, faster initial load

Implementation:
- Remove static import from Scanner.tsx (line 6)
- Replace with dynamic import at point of use (line 166+)
- page.tsx preloadOCR already uses dynamic import (no change needed)
- Improves initial page load time significantly

Build: Passes with zero errors. Compile time: 2.4s.
2026-04-17 10:40:42 +03:00

359 lines
14 KiB
TypeScript

'use client';
import { useEffect, useRef, useState } from 'react';
import { Html5Qrcode, Html5QrcodeSupportedFormats } from 'html5-qrcode';
import { RefreshCw, XCircle, Search } from 'lucide-react';
import { toast } from 'react-hot-toast';
interface ScannerProps {
onScanSuccess: (decodedText: string) => void;
onOCRMatch?: (text: string) => void;
paused?: boolean;
}
export default function Scanner({ onScanSuccess, onOCRMatch, paused }: ScannerProps) {
const html5QrCodeRef = useRef<Html5Qrcode | null>(null);
const [isStarted, setIsStarted] = useState(false);
const [error, setError] = useState<string | null>(null);
const [ocrProcessing, setOcrProcessing] = useState(false);
const [countdown, setCountdown] = useState(4);
const [zoom, setZoom] = useState(1);
const [maxZoom, setMaxZoom] = useState(1);
const [hasZoom, setHasZoom] = useState(false);
const [detectedWords, setDetectedWords] = useState<{ text: string, bbox: { x0: number, y0: number, x1: number, y1: number } }[]>([]);
const [isSelecting, setIsSelecting] = useState(false);
const [capturedImage, setCapturedImage] = useState<string | null>(null);
const isBusy = useRef(false);
const isTransitioning = useRef(false); // Flag to track start/stop transitions
const scannerId = "reader-container-unique";
useEffect(() => {
if (!html5QrCodeRef.current) {
html5QrCodeRef.current = new Html5Qrcode(scannerId);
}
const startScanner = async () => {
if (isBusy.current || isTransitioning.current) return;
isBusy.current = true;
isTransitioning.current = true;
try {
// If already scanning, we MUST stop it first
if (html5QrCodeRef.current?.isScanning) {
try {
await html5QrCodeRef.current.stop();
} catch (e) {
console.warn("Graceful stop failed, might be in transition:", e);
// If it's already in transition, we just return and wait for the next effect trigger
return;
}
}
const config = {
fps: 15,
qrbox: { width: 320, height: 320 },
aspectRatio: 1.0,
formatsToSupport: [
Html5QrcodeSupportedFormats.QR_CODE,
Html5QrcodeSupportedFormats.CODE_128,
Html5QrcodeSupportedFormats.CODE_39,
Html5QrcodeSupportedFormats.EAN_13,
Html5QrcodeSupportedFormats.UPC_A,
Html5QrcodeSupportedFormats.DATA_MATRIX
],
videoConstraints: {
facingMode: "environment"
}
};
await html5QrCodeRef.current?.start(
{ facingMode: "environment" },
config,
(decodedText) => {
onScanSuccess(decodedText);
},
() => {} // Ignore frame errors
);
const video = document.querySelector(`#${scannerId} video`) as HTMLVideoElement;
const track = (video?.srcObject as MediaStream)?.getVideoTracks()[0];
const caps = track?.getCapabilities() as any;
if (track && caps?.zoom) {
setHasZoom(true);
setMaxZoom(caps.zoom.max || 3);
}
setIsStarted(true);
setError(null);
} catch (err: any) {
const errorMsg = String(err);
if (!errorMsg.includes("is already starting") && !errorMsg.includes("already under transition")) {
console.error("Scanner failed", err);
setError(errorMsg || "Failed to access camera");
setIsStarted(false);
}
} finally {
isBusy.current = false;
isTransitioning.current = false;
}
};
if (!paused) {
startScanner();
}
return () => {
if (html5QrCodeRef.current?.isScanning) {
isTransitioning.current = true;
html5QrCodeRef.current.stop()
.then(() => {
setIsStarted(false);
})
.catch(e => console.error("Unmount stop failed", e))
.finally(() => {
isTransitioning.current = false;
});
}
};
}, [paused, onScanSuccess]);
// Automated OCR Countdown Timer
useEffect(() => {
if (!isStarted || paused || isSelecting || ocrProcessing) {
return;
}
const timer = setInterval(() => {
setCountdown(prev => {
if (prev <= 1) {
handleOCR();
return 4; // Reset to 4
}
return prev - 1;
});
}, 1000);
return () => clearInterval(timer);
}, [isStarted, paused, isSelecting, ocrProcessing]);
const handleOCR = async () => {
if (ocrProcessing) return;
setOcrProcessing(true);
try {
const video = document.querySelector(`#${scannerId} video`) as HTMLVideoElement;
if (!video) return;
const canvas = document.createElement('canvas');
const vWidth = video.videoWidth || 1280;
const vHeight = video.videoHeight || 720;
const cropFactor = 0.6;
const sw = vWidth * cropFactor;
const sh = vHeight * cropFactor;
const sx = (vWidth - sw) / 2;
const sy = (vHeight - sh) / 2;
canvas.width = 1200;
canvas.height = (sh / sw) * 1200;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.filter = 'grayscale(100%) contrast(180%) brightness(105%)';
ctx.drawImage(video, sx, sy, sw, sh, 0, 0, canvas.width, canvas.height);
}
// Lazy-load tesseract.js only when OCR is actually needed
const { createWorker } = await import('tesseract.js');
const workerPromise = createWorker('eng');
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error("OCR Engine timeout")), 8000));
const worker = await Promise.race([workerPromise, timeoutPromise]) as any;
const dataUrl = canvas.toDataURL('image/jpeg', 0.85);
setCapturedImage(dataUrl);
const result = await worker.recognize(dataUrl);
const data = result.data;
if (data && data.text && (!data.words || data.words.length === 0)) {
onOCRMatch?.(data.text);
await worker.terminate();
setCapturedImage(null);
return;
}
if (!data || !data.words || data.words.length === 0) {
await worker.terminate();
setCapturedImage(null);
return;
}
const words = data.words.map((w: any) => ({
text: w.text,
bbox: w.bbox
}));
setDetectedWords(words);
setIsSelecting(true);
await worker.terminate();
} catch (err) {
console.error("OCR failed", err);
} finally {
setOcrProcessing(false);
}
};
const handleWordSelect = (text: string) => {
onOCRMatch?.(text);
setIsSelecting(false);
setCapturedImage(null);
setDetectedWords([]);
setCountdown(4); // Restart countdown
};
const cn = (...classes: any[]) => classes.filter(Boolean).join(' ');
return (
<div className="w-full max-w-md mx-auto flex flex-col gap-6">
{/* Video Viewport Area */}
<div className="relative w-full aspect-square overflow-hidden rounded-[2.5rem] shadow-[0_20px_50px_rgba(0,0,0,0.5)] bg-slate-950 border-2 border-slate-800/50">
<div className="absolute inset-0 z-10 pointer-events-none flex items-center justify-center">
<div className="w-[85%] h-[85%] border border-primary/30 rounded-[2rem] relative">
<div className="absolute top-0 left-0 w-10 h-10 border-t-4 border-l-4 border-primary rounded-tl-2xl shadow-[0_0_15px_rgba(59,130,246,0.5)]" />
<div className="absolute top-0 right-0 w-10 h-10 border-t-4 border-r-4 border-primary rounded-tr-2xl shadow-[0_0_15px_rgba(59,130,246,0.5)]" />
<div className="absolute bottom-0 left-0 w-10 h-10 border-b-4 border-l-4 border-primary rounded-bl-2xl shadow-[0_0_15px_rgba(59,130,246,0.5)]" />
<div className="absolute bottom-0 right-0 w-10 h-10 border-b-4 border-r-4 border-primary rounded-br-2xl shadow-[0_0_15px_rgba(59,130,246,0.5)]" />
{isStarted && !paused && !isSelecting && (
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-transparent via-primary to-transparent shadow-[0_0_20px_rgba(59,130,246,0.8)] animate-scan-fast" />
)}
</div>
</div>
<div id={scannerId} className="w-full h-full bg-slate-900 object-cover" />
{/* Selection UI */}
{isSelecting && capturedImage && (
<div className="absolute inset-0 z-50 bg-slate-950 flex flex-col">
<div className="relative flex-1 bg-black flex items-center justify-center overflow-hidden">
<img src={capturedImage || undefined} className="max-w-full max-h-full object-contain" id="ocr-canvas-preview" />
<div className="absolute inset-0 flex items-center justify-center">
<div className="relative" style={{ width: '100%', height: '100%' }}>
{detectedWords.map((w, i) => (
<button
key={i}
onClick={() => handleWordSelect(w.text)}
className="absolute border border-primary bg-primary/20 rounded-sm active:bg-primary/50 transition-colors pointer-events-auto"
style={{
left: `${(w.bbox.x0 / 1600) * 100}%`,
top: `${(w.bbox.y0 / (1600 * (9/16))) * 100}%`,
width: `${((w.bbox.x1 - w.bbox.x0) / 1600) * 100}%`,
height: `${((w.bbox.y1 - w.bbox.y0) / (1600 * (9/16))) * 100}%`,
}}
/>
))}
</div>
</div>
</div>
<div className="p-6 bg-slate-900/90 border-t border-slate-800 flex flex-col gap-4">
<div className="flex flex-col items-center gap-1">
<p className="text-sm font-black text-white italic text-center">Protocol Detected</p>
<p className="text-xs text-slate-500 font-black text-center">Select identity from label matrix</p>
</div>
<button
onClick={() => { setIsSelecting(false); setCapturedImage(null); setCountdown(4); }}
className="w-full py-4 bg-slate-800 hover:bg-slate-700 text-white rounded-2xl font-black text-xs transition-all active:scale-95 border border-slate-700"
>
Abort Cycle
</button>
</div>
</div>
)}
{!isStarted && !error && (
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center bg-slate-900 text-slate-300 gap-4">
<RefreshCw className="w-8 h-8 animate-spin text-primary" />
<p className="text-sm font-medium">Initializing camera...</p>
</div>
)}
{error && (
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center bg-slate-900 text-slate-300 px-8 text-center gap-4">
<XCircle className="w-10 h-10 text-red-500" />
<div>
<p className="font-bold text-white">Camera Error</p>
<p className="text-xs text-slate-400 mt-1">{error}</p>
</div>
<button
onClick={() => window.location.reload()}
className="mt-4 px-6 py-2 bg-slate-800 rounded-full text-sm font-bold hover:bg-slate-700 transition-colors"
>
Try Again
</button>
</div>
)}
</div>
{/* External Controls Area */}
<div className="flex flex-col gap-4 bg-slate-900/40 p-5 rounded-[2.5rem] border border-slate-800/50 shadow-2xl">
<div className="flex items-center gap-3 w-full">
{hasZoom && (
<button
onClick={async () => {
let nextZoom = 1;
if (zoom === 1) nextZoom = Math.min(2, maxZoom);
else if (zoom < maxZoom / 2) nextZoom = Math.floor(maxZoom / 2);
else if (zoom < maxZoom) nextZoom = maxZoom;
else nextZoom = 1;
const video = document.querySelector(`#${scannerId} video`) as HTMLVideoElement;
const track = (video?.srcObject as MediaStream)?.getVideoTracks()[0];
if (track) {
await track.applyConstraints({ advanced: [{ zoom: nextZoom }] as any });
setZoom(nextZoom);
}
}}
className="h-14 px-5 bg-slate-800/80 hover:bg-slate-700 border border-slate-700 text-white rounded-2xl flex flex-col items-center justify-center shadow-lg transition-all active:scale-95 shrink-0"
>
<span className="text-xs font-black tabular-nums">{zoom.toFixed(1)}x</span>
<span className="text-xs text-primary font-black tracking-tighter">Zoom</span>
</button>
)}
<div className="flex-1 h-14 bg-slate-950/40 border border-slate-800/50 rounded-2xl flex items-center justify-center gap-3 px-4 relative overflow-hidden group">
{ocrProcessing ? (
<>
<RefreshCw className="animate-spin text-primary" size={18} />
<span className="text-xs font-black text-slate-200 leading-none">Analyzing</span>
</>
) : (
<>
<Search className={cn("text-slate-600 transition-colors group-hover:text-primary", !isStarted && "opacity-20")} size={18} />
<div className="flex flex-col">
<span className="text-xs text-slate-500 font-black leading-none">Optical Assist</span>
<span className="text-xs font-black text-primary leading-tight tabular-nums">
{countdown === 0 ? "Scanning" : `${countdown}s Cycle`}
</span>
</div>
</>
)}
{/* Visual Progress Bar */}
<div
className="absolute bottom-0 left-0 h-0.5 bg-primary/40 transition-all duration-1000 ease-linear shadow-[0_0_10px_rgba(59,130,246,0.5)]"
style={{ width: `${((4 - countdown) / 4) * 100}%` }}
/>
</div>
</div>
<div className="w-full flex justify-center items-center gap-2">
<div className="w-1 h-1 rounded-full bg-green-500 animate-pulse" />
<p className="text-xs text-slate-600 font-black">
Optical Data Stream Active
</p>
</div>
</div>
</div>
);
}