diff --git a/frontend/index.html b/frontend/index.html index 98b620b40..a898f3dc4 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -19,8 +19,5 @@
- - - diff --git a/frontend/src/core/pages/MobileScannerPage.tsx b/frontend/src/core/pages/MobileScannerPage.tsx index 5f0124a27..ca797a6c3 100644 --- a/frontend/src/core/pages/MobileScannerPage.tsx +++ b/frontend/src/core/pages/MobileScannerPage.tsx @@ -20,14 +20,7 @@ import PhotoCameraRoundedIcon from "@mui/icons-material/PhotoCameraRounded"; import UploadRoundedIcon from "@mui/icons-material/UploadRounded"; import AddPhotoAlternateRoundedIcon from "@mui/icons-material/AddPhotoAlternateRounded"; import CheckCircleRoundedIcon from "@mui/icons-material/CheckCircleRounded"; - -// jscanify is loaded via script tag in index.html as a global -declare global { - interface Window { - jscanify: any; - cv: any; - } -} +import { loadJscanify } from "@app/utils/loadJscanify"; /** * MobileScannerPage @@ -136,69 +129,36 @@ export default function MobileScannerPage() { validateSession(); }, [sessionId, t]); - // Initialize jscanify scanner and wait for OpenCV (loaded via script tags in index.html) useEffect(() => { - let retryCount = 0; - const MAX_RETRIES = 50; // 5 seconds max wait + let cancelled = false; - const initScanner = () => { - // Check if both OpenCV and jscanify are loaded - if (!(window as any).cv || !(window as any).cv.Mat) { - retryCount++; - if (retryCount < MAX_RETRIES) { - if (retryCount % 10 === 1) { - setLoadingStatus( - `Loading OpenCV... (${retryCount}/${MAX_RETRIES})`, - ); - console.log( - `[${retryCount}/${MAX_RETRIES}] Waiting for OpenCV to load...`, - ); - } - setTimeout(initScanner, 100); - } else { - const error = - "OpenCV failed to load after 5 seconds. Check that /vendor/jscanify/opencv.js is accessible."; - setLoadingStatus("OpenCV load failed ✗"); - console.error(error); + loadJscanify({ + onStatus: (status) => { + if (!cancelled) setLoadingStatus(status); + }, + }) + .then(() => { + if (cancelled) return; + try { + scannerRef.current = new window.jscanify!(); + setOpenCvReady(true); + console.log("✓ jscanify initialized with OpenCV"); + } catch (err) { + setLoadingStatus("jscanify init failed ✗"); + console.error("Failed to initialize jscanify:", err); } - return; - } + }) + .catch((err) => { + if (cancelled) return; + setLoadingStatus( + `Scanner library failed to load ✗: ${(err as Error).message}`, + ); + console.error("Failed to load jscanify:", err); + }); - if (!window.jscanify) { - retryCount++; - if (retryCount < MAX_RETRIES) { - if (retryCount % 10 === 1) { - setLoadingStatus( - `Loading jscanify... (${retryCount}/${MAX_RETRIES})`, - ); - console.log( - `[${retryCount}/${MAX_RETRIES}] Waiting for jscanify to load...`, - ); - } - setTimeout(initScanner, 100); - } else { - const error = - "jscanify failed to load after 5 seconds. Check that /vendor/jscanify/jscanify.js is accessible."; - setLoadingStatus("jscanify load failed ✗"); - console.error(error); - } - return; - } - - try { - scannerRef.current = new window.jscanify(); - setOpenCvReady(true); - // Don't set status here - let camera/detection effects control status from now on - console.log("✓ jscanify initialized with OpenCV"); - } catch (err) { - setLoadingStatus("jscanify init failed ✗"); - console.error("Failed to initialize jscanify:", err); - } + return () => { + cancelled = true; }; - - // Start initialization - setLoadingStatus("Loading OpenCV..."); - initScanner(); }, []); // Initialize camera diff --git a/frontend/src/core/utils/loadJscanify.ts b/frontend/src/core/utils/loadJscanify.ts new file mode 100644 index 000000000..2057ddbdc --- /dev/null +++ b/frontend/src/core/utils/loadJscanify.ts @@ -0,0 +1,115 @@ +declare global { + interface Window { + cv?: any; + jscanify?: any; + } +} + +const OPENCV_SRC = "/vendor/jscanify/opencv.js"; +const JSCANIFY_SRC = "/vendor/jscanify/jscanify.js"; + +let loadPromise: Promise | null = null; + +function injectScript(src: string): Promise { + return new Promise((resolve, reject) => { + // data-loaded: load event fired. data-loading: in flight. + // Neither: foreign tag (e.g. HMR-preserved); trust the downstream + // global check rather than wait on a load event that may already + // have dispatched. + const existing = document.querySelector( + `script[src="${src}"]`, + ); + if (existing) { + if (existing.dataset.loaded === "true") { + resolve(); + return; + } + if (existing.dataset.loading === "true") { + existing.addEventListener("load", () => resolve(), { once: true }); + existing.addEventListener( + "error", + () => reject(new Error(`Failed to load ${src}`)), + { once: true }, + ); + return; + } + resolve(); + return; + } + + const script = document.createElement("script"); + script.src = src; + script.async = true; + script.dataset.loading = "true"; + script.onload = () => { + script.dataset.loaded = "true"; + delete script.dataset.loading; + resolve(); + }; + script.onerror = () => { + delete script.dataset.loading; + reject(new Error(`Failed to load ${src}`)); + }; + document.head.appendChild(script); + }); +} + +function waitForGlobal( + check: () => boolean, + name: string, + timeoutMs: number, + onProgress?: (elapsedMs: number) => void, +): Promise { + return new Promise((resolve, reject) => { + const start = Date.now(); + const tick = () => { + if (check()) { + resolve(); + return; + } + const elapsed = Date.now() - start; + if (elapsed > timeoutMs) { + reject(new Error(`Timed out waiting for ${name}`)); + return; + } + onProgress?.(elapsed); + setTimeout(tick, 100); + }; + tick(); + }); +} + +export interface LoadJscanifyOptions { + onStatus?: (status: string) => void; +} + +export function loadJscanify(options: LoadJscanifyOptions = {}): Promise { + const { onStatus } = options; + + if (loadPromise) return loadPromise; + + loadPromise = (async () => { + onStatus?.("Loading OpenCV..."); + await injectScript(OPENCV_SRC); + // OpenCV's script load event fires before the WASM runtime is ready. + await waitForGlobal( + () => !!window.cv && !!window.cv.Mat, + "OpenCV runtime (cv.Mat)", + 15000, + (elapsed) => { + if (elapsed > 2000) onStatus?.("Initializing OpenCV runtime..."); + }, + ); + + onStatus?.("Loading jscanify..."); + await injectScript(JSCANIFY_SRC); + await waitForGlobal(() => !!window.jscanify, "jscanify global", 5000); + + onStatus?.("Scanner ready"); + })().catch((err) => { + loadPromise = null; + throw err; + }); + + return loadPromise; +}