Merge branch 'main' into next

This commit is contained in:
nocobase[bot]
2026-07-23 00:48:21 +00:00
4 changed files with 442 additions and 75 deletions
@@ -11,7 +11,7 @@ import { FileImageOutlined, LeftOutlined } from '@ant-design/icons';
import { css } from '@emotion/css';
import { Button, message, theme } from 'antd';
import { Html5Qrcode } from 'html5-qrcode';
import React, { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
import React, { useCallback, useEffect, useId, useRef, useState } from 'react';
import ReactDOM from 'react-dom';
import { useTranslation } from 'react-i18next';
import { ScanBox } from './ScanBox';
@@ -26,6 +26,14 @@ type CodeScannerProps = {
};
const MAX_CODE_IMAGE_SIZE = 10 * 1024 * 1024;
const SCANNER_RENDER_WIDTH = 1280;
function getViewportSize() {
return {
width: Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0),
height: Math.max(document.documentElement.clientHeight || 0, window.innerHeight || 0),
};
}
function CodeScannerContent({ visible, formatsToSupport, onClose, onScanSuccess }: CodeScannerProps) {
const { t } = useTranslation();
@@ -34,16 +42,15 @@ function CodeScannerContent({ visible, formatsToSupport, onClose, onScanSuccess
const scannerElementId = `code-scanner-${inputId}`;
const imgUploaderRef = useRef<HTMLInputElement>(null);
const [cameraAvailable, setCameraAvailable] = useState(false);
const [scannerSize, setScannerSize] = useState({ width: 0, height: 0 });
const [viewport, setViewport] = useState(getViewportSize);
const viewport = useMemo(() => {
const width = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0);
const height = Math.max(document.documentElement.clientHeight || 0, window.innerHeight || 0);
return { width, height };
}, []);
const scanBoxSize = useMemo(
() => getCodeScanBoxSize(viewport.width, viewport.height),
[viewport.height, viewport.width],
);
const previewScale = scannerSize.width
? Math.max(viewport.width / scannerSize.width, viewport.height / scannerSize.height)
: Math.min(1, viewport.width / SCANNER_RENDER_WIDTH);
const visibleScanBoxSize = scannerSize.width
? getCodeScanBoxSize(viewport.width, viewport.height)
: { width: 0, height: 0 };
const showScanFailure = useCallback(() => {
message.error(t('Code recognition failed, please scan again'));
@@ -76,12 +83,18 @@ function CodeScannerContent({ visible, formatsToSupport, onClose, onScanSuccess
enabled: visible && cameraAvailable,
elementId: scannerElementId,
formatsToSupport,
scanBoxSize,
onScannerSizeChanged: setScannerSize,
onScanSuccess: handleScanSuccess,
onScanFailure: showScanFailure,
onCameraStartFailure: showCameraStartFailure,
});
useEffect(() => {
const handleResize = () => setViewport(getViewportSize());
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
useEffect(() => {
if (!visible) {
return;
@@ -152,18 +165,22 @@ function CodeScannerContent({ visible, formatsToSupport, onClose, onScanSuccess
inset: 0;
overflow: hidden;
`;
const scannerSurfaceClass = css`
position: absolute;
top: 50%;
left: 50%;
transform-origin: center;
`;
const scannerClass = css`
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
line-height: 0;
video {
position: absolute !important;
inset: 0 !important;
width: 100% !important;
height: 100% !important;
object-fit: cover !important;
height: auto !important;
max-width: none !important;
display: block !important;
}
#qr-shaded-region {
@@ -191,26 +208,36 @@ function CodeScannerContent({ visible, formatsToSupport, onClose, onScanSuccess
return (
<div className={rootClass}>
<div className={scannerWrapperClass}>
<div id={scannerElementId} className={scannerClass} />
<div
className={scannerSurfaceClass}
style={{
width: `${SCANNER_RENDER_WIDTH}px`,
transform: `translate(-50%, -50%) scale(${previewScale})`,
}}
>
<div id={scannerElementId} className={scannerClass} />
</div>
</div>
{cameraAvailable && scannerSize.width > 0 && (
<ScanBox
style={{
position: 'fixed',
top: `${(viewport.height - visibleScanBoxSize.height) / 2}px`,
left: `${(viewport.width - visibleScanBoxSize.width) / 2}px`,
width: `${visibleScanBoxSize.width}px`,
height: `${visibleScanBoxSize.height}px`,
}}
/>
)}
<Button
aria-label={t('Close')}
className={closeButtonClass}
icon={<LeftOutlined />}
type="text"
onClick={onClose}
/>
{cameraAvailable && (
<>
<ScanBox
style={{
position: 'fixed',
top: `${(viewport.height - scanBoxSize.height) / 2}px`,
left: `${(viewport.width - scanBoxSize.width) / 2}px`,
width: `${scanBoxSize.width}px`,
height: `${scanBoxSize.height}px`,
}}
/>
<Button
aria-label={t('Close')}
className={closeButtonClass}
icon={<LeftOutlined />}
type="text"
onClick={onClose}
/>
<Button className={albumClass} icon={<FileImageOutlined />} type="text" onClick={handleImageButtonClick}>
{t('Album')}
</Button>
@@ -0,0 +1,101 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { act, render, waitFor } from '@testing-library/react';
import React from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { CodeScanner } from '../CodeScanner';
const mocks = vi.hoisted(() => ({
getCameras: vi.fn().mockResolvedValue([{ id: 'rear-camera' }]),
}));
vi.mock('html5-qrcode', () => ({
Html5Qrcode: {
getCameras: mocks.getCameras,
},
Html5QrcodeScannerState: {
PAUSED: 3,
SCANNING: 2,
},
Html5QrcodeSupportedFormats: {
CODABAR: 2,
CODE_128: 5,
CODE_39: 3,
CODE_93: 4,
DATA_MATRIX: 6,
EAN_13: 9,
EAN_8: 10,
ITF: 8,
PDF_417: 11,
QR_CODE: 0,
UPC_A: 14,
UPC_E: 15,
},
}));
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
vi.mock('../useCodeScanner', async (importOriginal) => {
const actual = await importOriginal<typeof import('../useCodeScanner')>();
const ReactModule = await import('react');
return {
...actual,
useCodeScanner: ({
enabled,
onScannerSizeChanged,
}: {
enabled: boolean;
onScannerSizeChanged?: (size: { width: number; height: number }) => void;
}) => {
ReactModule.useEffect(() => {
if (enabled) {
onScannerSizeChanged?.({ width: 1280, height: 720 });
}
}, [enabled, onScannerSizeChanged]);
return { startScanFile: vi.fn() };
},
};
});
describe('CodeScanner', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('keeps the camera preview covering portrait and landscape viewports', async () => {
let viewportWidth = 390;
let viewportHeight = 844;
vi.spyOn(window, 'innerWidth', 'get').mockImplementation(() => viewportWidth);
vi.spyOn(window, 'innerHeight', 'get').mockImplementation(() => viewportHeight);
render(<CodeScanner visible onClose={() => undefined} onScanSuccess={() => undefined} />);
await waitFor(() => expect(document.getElementById('code-scan-box')).toBeInTheDocument());
const scannerElement = document.querySelector<HTMLElement>('[id^="code-scanner-"]');
const scannerSurface = scannerElement?.parentElement;
const scanBox = document.getElementById('code-scan-box');
expect(scannerSurface?.style.transform).toBe(`translate(-50%, -50%) scale(${844 / 720})`);
expect(scanBox).toHaveStyle({ width: '351px', height: '540px' });
viewportWidth = 844;
viewportHeight = 390;
act(() => window.dispatchEvent(new Event('resize')));
await waitFor(() => {
expect(scannerSurface?.style.transform).toBe(`translate(-50%, -50%) scale(${844 / 1280})`);
expect(scanBox).toHaveStyle({ width: '759px', height: '273px' });
});
});
});
@@ -7,13 +7,15 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import React, { useCallback } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DEFAULT_CODE_FORMATS, getCodeScanBoxSize, useCodeScanner } from '../useCodeScanner';
import { DEFAULT_CODE_FORMATS, getCodeScanBoxSize, scanQrVideoFrame, useCodeScanner } from '../useCodeScanner';
type MockScannerInstance = {
applyVideoConstraints: ReturnType<typeof vi.fn>;
clear: ReturnType<typeof vi.fn>;
getRunningTrackCapabilities: ReturnType<typeof vi.fn>;
getState: ReturnType<typeof vi.fn>;
scanFileV2: ReturnType<typeof vi.fn>;
start: ReturnType<typeof vi.fn>;
@@ -24,18 +26,24 @@ const mocks = vi.hoisted(() => {
const start = vi.fn().mockResolvedValue(null);
const stop = vi.fn().mockResolvedValue(null);
const clear = vi.fn();
const applyVideoConstraints = vi.fn().mockResolvedValue(undefined);
const getRunningTrackCapabilities = vi.fn(() => ({}));
const getState = vi.fn(() => 1);
const scanFileV2 = vi.fn();
const Html5Qrcode = vi.fn(function MockHtml5Qrcode(this: MockScannerInstance) {
this.start = start;
this.stop = stop;
this.clear = clear;
this.applyVideoConstraints = applyVideoConstraints;
this.getRunningTrackCapabilities = getRunningTrackCapabilities;
this.getState = getState;
this.scanFileV2 = scanFileV2;
});
return {
applyVideoConstraints,
clear,
getRunningTrackCapabilities,
getState,
Html5Qrcode,
scanFileV2,
@@ -77,18 +85,15 @@ vi.mock('jsqr', () => jsQrMocks);
function ScannerHost({
onCameraStartFailure,
onScanFailure,
scanBoxSize,
}: {
onCameraStartFailure?: (error: unknown) => void;
onScanFailure?: () => void;
scanBoxSize?: { width: number; height: number };
} = {}) {
const handleScanSuccess = useCallback(() => undefined, []);
useCodeScanner({
elementId: 'scanner',
enabled: true,
scanBoxSize,
onCameraStartFailure,
onScanFailure,
onScanSuccess: handleScanSuccess,
@@ -159,6 +164,11 @@ function stubCanvas() {
describe('useCodeScanner', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.applyVideoConstraints.mockResolvedValue(undefined);
mocks.getRunningTrackCapabilities.mockReturnValue({});
mocks.start.mockResolvedValue(null);
mocks.stop.mockResolvedValue(null);
mocks.getState.mockReturnValue(1);
jsQrMocks.default.mockReturnValue(undefined);
});
@@ -188,32 +198,73 @@ describe('useCodeScanner', () => {
| { qrbox?: (width: number, height: number) => { width: number; height: number } }
| undefined;
expect(config?.qrbox?.(390, 844)).toEqual({ width: 319, height: 240 });
expect(getCodeScanBoxSize(390, 844)).toEqual({ width: 319, height: 240 });
expect(config?.qrbox?.(1280, 720)).toEqual({ width: 1152, height: 504 });
expect(getCodeScanBoxSize(1280, 720)).toEqual({ width: 1152, height: 504 });
});
it('can use the visible viewport scan box when the camera video is wider than the viewport', async () => {
render(<ScannerHost scanBoxSize={{ width: 319, height: 240 }} />);
it('uses optimized camera constraints without requiring a user-selected scan mode', async () => {
render(<ScannerHost />);
await waitFor(() => expect(mocks.start).toHaveBeenCalled());
const config = mocks.start.mock.calls[0]?.[1] as
| { qrbox?: (width: number, height: number) => { width: number; height: number } }
| {
disableFlip?: boolean;
fps?: number;
qrbox?: (width: number, height: number) => { width: number; height: number };
videoConstraints?: MediaTrackConstraints;
}
| undefined;
expect(config?.qrbox?.(1200, 844)).toEqual({ width: 319, height: 240 });
expect(config?.qrbox?.(1280, 720)).toEqual({ width: 1152, height: 504 });
expect(config).toMatchObject({
disableFlip: false,
fps: 8,
videoConstraints: {
facingMode: { ideal: 'environment' },
width: { ideal: 1920 },
height: { ideal: 1080 },
frameRate: { ideal: 30 },
},
});
expect(mocks.Html5Qrcode).toHaveBeenCalledWith('scanner', {
formatsToSupport: DEFAULT_CODE_FORMATS,
verbose: false,
});
});
it('clamps the scan box to the camera viewfinder size', async () => {
render(<ScannerHost scanBoxSize={{ width: 319, height: 240 }} />);
it('decodes a centered QR code from the raw camera frame fast path', () => {
jsQrMocks.default.mockReturnValueOnce({ data: 'FAST-QR' });
stubCanvas();
const video = document.createElement('video');
Object.defineProperties(video, {
clientHeight: { value: 720 },
clientWidth: { value: 1280 },
readyState: { value: HTMLMediaElement.HAVE_CURRENT_DATA },
videoHeight: { value: 1080 },
videoWidth: { value: 1920 },
});
const canvas = document.createElement('canvas');
await waitFor(() => expect(mocks.start).toHaveBeenCalled());
expect(scanQrVideoFrame(video, canvas)).toBe('FAST-QR');
const config = mocks.start.mock.calls[0]?.[1] as
| { qrbox?: (width: number, height: number) => { width: number; height: number } }
| undefined;
const context = canvas.getContext('2d');
expect(context?.drawImage).toHaveBeenCalledWith(video, 96, 162, 1728, 756, 0, 0, 960, 420);
expect(jsQrMocks.default).toHaveBeenCalledWith(expect.any(Uint8ClampedArray), 960, 420, {
inversionAttempts: 'dontInvert',
});
});
expect(config?.qrbox?.(200, 160)).toEqual({ width: 200, height: 160 });
it('enables continuous camera focus when the device supports it', async () => {
mocks.getRunningTrackCapabilities.mockReturnValue({ focusMode: ['manual', 'continuous'] });
render(<ScannerHost />);
await waitFor(() =>
expect(mocks.applyVideoConstraints).toHaveBeenCalledWith({
advanced: [{ focusMode: 'continuous' }],
}),
);
});
it('reports camera start failures through the dedicated handler', async () => {
@@ -228,6 +279,51 @@ describe('useCodeScanner', () => {
expect(handleScanFailure).not.toHaveBeenCalled();
});
it('stops a camera that finishes starting after the scanner unmounts', async () => {
let resolveStart: ((value: null) => void) | undefined;
mocks.start.mockReturnValueOnce(
new Promise<null>((resolve) => {
resolveStart = resolve;
}),
);
mocks.getState.mockReturnValueOnce(1).mockReturnValue(2);
const { unmount } = render(<ScannerHost />);
await waitFor(() => expect(mocks.start).toHaveBeenCalled());
unmount();
await act(async () => {
resolveStart?.(null);
});
await waitFor(() => expect(mocks.stop).toHaveBeenCalledTimes(1));
expect(mocks.clear).toHaveBeenCalled();
});
it('ignores a camera start failure after the scanner unmounts', async () => {
let rejectStart: ((error: Error) => void) | undefined;
mocks.start.mockReturnValueOnce(
new Promise<null>((_resolve, reject) => {
rejectStart = reject;
}),
);
const handleCameraStartFailure = vi.fn();
const handleScanFailure = vi.fn();
const { unmount } = render(
<ScannerHost onCameraStartFailure={handleCameraStartFailure} onScanFailure={handleScanFailure} />,
);
await waitFor(() => expect(mocks.start).toHaveBeenCalled());
unmount();
await act(async () => {
rejectStart?.(new Error('Camera start canceled'));
});
expect(handleCameraStartFailure).not.toHaveBeenCalled();
expect(handleScanFailure).not.toHaveBeenCalled();
});
it('uses jsQR first for Safari uploaded QR images', async () => {
const handleScanSuccess = vi.fn();
jsQrMocks.default.mockReturnValueOnce({ data: 'JSQR-CODE' });
@@ -9,7 +9,7 @@
import { Html5Qrcode, Html5QrcodeScannerState, Html5QrcodeSupportedFormats } from 'html5-qrcode';
import jsQR from 'jsqr';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import type { CodeFormatsToSupport } from './types';
type ScannerSize = {
@@ -21,7 +21,6 @@ type UseCodeScannerOptions = {
enabled: boolean;
elementId: string;
formatsToSupport?: CodeFormatsToSupport;
scanBoxSize?: ScannerSize;
onScannerSizeChanged?: (size: ScannerSize) => void;
onScanSuccess: (text: string) => void;
onScanFailure?: () => void;
@@ -38,7 +37,18 @@ type JsQRImageTransform = {
threshold?: number;
};
type FocusMediaTrackCapabilities = MediaTrackCapabilities & {
focusMode?: string[];
};
type FocusMediaTrackConstraintSet = MediaTrackConstraintSet & {
focusMode?: string;
};
const QR_SCAN_IMAGE_SIZES = [3200, 2400, 1600, 1000];
const LIVE_QR_SCAN_INTERVAL = 120;
const LIVE_QR_SCAN_MAX_WIDTH = 960;
const LIVE_QR_SCAN_MAX_HEIGHT = 540;
const QR_SCAN_IMAGE_TRANSFORMS: JsQRImageTransform[] = [
{},
{ contrast: 3, threshold: 105 },
@@ -66,8 +76,72 @@ export const DEFAULT_CODE_FORMATS: CodeFormatsToSupport = [
export function getCodeScanBoxSize(width: number, height: number) {
return {
width: Math.floor(Math.min(width * 0.82, 520)),
height: Math.floor(Math.min(height * 0.32, 240)),
width: Math.floor(Math.min((width * 90) / 100, 1152)),
height: Math.floor(Math.min((height * 70) / 100, 540)),
};
}
export function scanQrVideoFrame(video: HTMLVideoElement, canvas: HTMLCanvasElement) {
if (!video.videoWidth || !video.videoHeight || video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) {
return;
}
const viewfinderWidth = video.clientWidth || video.videoWidth;
const viewfinderHeight = video.clientHeight || video.videoHeight;
const scanBoxSize = getCodeScanBoxSize(viewfinderWidth, viewfinderHeight);
const sourceWidth = Math.min(video.videoWidth, Math.floor(scanBoxSize.width * (video.videoWidth / viewfinderWidth)));
const sourceHeight = Math.min(
video.videoHeight,
Math.floor(scanBoxSize.height * (video.videoHeight / viewfinderHeight)),
);
const sourceX = Math.floor((video.videoWidth - sourceWidth) / 2);
const sourceY = Math.floor((video.videoHeight - sourceHeight) / 2);
const targetScale = Math.min(1, LIVE_QR_SCAN_MAX_WIDTH / sourceWidth, LIVE_QR_SCAN_MAX_HEIGHT / sourceHeight);
const targetWidth = Math.max(1, Math.floor(sourceWidth * targetScale));
const targetHeight = Math.max(1, Math.floor(sourceHeight * targetScale));
if (canvas.width !== targetWidth) {
canvas.width = targetWidth;
}
if (canvas.height !== targetHeight) {
canvas.height = targetHeight;
}
const context = canvas.getContext('2d', { willReadFrequently: true });
if (!context) {
return;
}
context.drawImage(video, sourceX, sourceY, sourceWidth, sourceHeight, 0, 0, targetWidth, targetHeight);
const imageData = context.getImageData(0, 0, targetWidth, targetHeight);
return jsQR(imageData.data, imageData.width, imageData.height, { inversionAttempts: 'dontInvert' })?.data;
}
function startLiveQrScan(elementId: string, onScanSuccess: (text: string) => void) {
const canvas = document.createElement('canvas');
let timer: number | undefined;
let stopped = false;
const scan = () => {
if (stopped) {
return;
}
const video = document.getElementById(elementId)?.querySelector('video');
if (video) {
const decodedText = scanQrVideoFrame(video, canvas);
if (decodedText) {
stopped = true;
onScanSuccess(decodedText);
return;
}
}
timer = window.setTimeout(scan, LIVE_QR_SCAN_INTERVAL);
};
timer = window.setTimeout(scan, 0);
return () => {
stopped = true;
if (timer !== undefined) {
window.clearTimeout(timer);
}
};
}
@@ -92,6 +166,19 @@ async function stopScanner(scanner?: Html5Qrcode, options: { clear?: boolean } =
}
}
async function enableContinuousFocus(scanner: Html5Qrcode) {
try {
const capabilities = scanner.getRunningTrackCapabilities() as FocusMediaTrackCapabilities;
if (!capabilities.focusMode?.includes('continuous')) {
return;
}
const focusConstraints: FocusMediaTrackConstraintSet = { focusMode: 'continuous' };
await scanner.applyVideoConstraints({ advanced: [focusConstraints] });
} catch {
// Some browsers expose incomplete camera capability APIs. Scanning should continue without explicit focus control.
}
}
function isSafariBrowser() {
const { userAgent, vendor } = navigator;
return /Apple/i.test(vendor) && /Safari/i.test(userAgent) && !/CriOS|FxiOS|EdgiOS|Chrome/i.test(userAgent);
@@ -216,40 +303,95 @@ export function useCodeScanner({
enabled,
elementId,
formatsToSupport,
scanBoxSize,
onScannerSizeChanged,
onScanSuccess,
onScanFailure,
onCameraStartFailure,
}: UseCodeScannerOptions) {
const [scanner, setScanner] = useState<Html5Qrcode>();
const liveQrScanStopRef = useRef<() => void>();
const scanSucceededRef = useRef(false);
const scanSessionRef = useRef(0);
const stopLiveQrScan = useCallback(() => {
liveQrScanStopRef.current?.();
liveQrScanStopRef.current = undefined;
}, []);
const cancelActiveScan = useCallback(() => {
scanSessionRef.current += 1;
stopLiveQrScan();
}, [stopLiveQrScan]);
const reportScanSuccess = useCallback(
(text: string) => {
if (scanSucceededRef.current) {
return;
}
scanSucceededRef.current = true;
stopLiveQrScan();
onScanSuccess(text);
},
[onScanSuccess, stopLiveQrScan],
);
const startScanCamera = useCallback(
async (scannerInstance: Html5Qrcode) => {
await scannerInstance.start(
{ facingMode: 'environment' },
{
fps: 10,
qrbox(width, height) {
onScannerSizeChanged?.({ width, height });
return clampCodeScanBoxSize(scanBoxSize ?? getCodeScanBoxSize(width, height), width, height);
cancelActiveScan();
const scanSession = scanSessionRef.current;
scanSucceededRef.current = false;
try {
await scannerInstance.start(
{ facingMode: 'environment' },
{
fps: 8,
disableFlip: false,
videoConstraints: {
facingMode: { ideal: 'environment' },
width: { ideal: 1920 },
height: { ideal: 1080 },
frameRate: { ideal: 30 },
},
qrbox(width, height) {
onScannerSizeChanged?.({ width, height });
return clampCodeScanBoxSize(getCodeScanBoxSize(width, height), width, height);
},
},
},
(decodedText) => {
onScanSuccess(decodedText);
},
undefined,
);
(decodedText) => {
reportScanSuccess(decodedText);
},
undefined,
);
} catch (error) {
if (scanSession !== scanSessionRef.current) {
return;
}
throw error;
}
if (scanSession !== scanSessionRef.current) {
try {
await stopScanner(scannerInstance, { clear: true });
} catch {
// The scanner may already have been cleared by the canceled session cleanup.
}
return;
}
if (shouldScanQrWithJsQR(formatsToSupport)) {
liveQrScanStopRef.current = startLiveQrScan(elementId, reportScanSuccess);
}
await enableContinuousFocus(scannerInstance);
},
[onScanSuccess, onScannerSizeChanged, scanBoxSize],
[cancelActiveScan, elementId, formatsToSupport, onScannerSizeChanged, reportScanSuccess],
);
const startScanFile = useCallback(
async (file: File) => {
cancelActiveScan();
scanSucceededRef.current = false;
if (isSafariBrowser() && shouldScanQrWithJsQR(formatsToSupport)) {
try {
const decodedText = await scanFileWithJsQR(file, formatsToSupport);
onScanSuccess(decodedText);
reportScanSuccess(decodedText);
return;
} catch {
// Fall through to html5-qrcode so barcode uploads still work in Safari.
@@ -263,13 +405,13 @@ export function useCodeScanner({
await stopScanner(scanner);
try {
const result = await scanner.scanFileV2(file, false);
onScanSuccess(result.decodedText);
reportScanSuccess(result.decodedText);
} catch {
onScanFailure?.();
await startScanCamera(scanner);
}
},
[formatsToSupport, onScanFailure, onScanSuccess, scanner, startScanCamera],
[cancelActiveScan, formatsToSupport, onScanFailure, reportScanSuccess, scanner, startScanCamera],
);
useEffect(() => {
@@ -291,9 +433,10 @@ export function useCodeScanner({
});
return () => {
cancelActiveScan();
stopScanner(scannerInstance, { clear: true }).catch(() => undefined);
};
}, [elementId, enabled, formatsToSupport, onCameraStartFailure, onScanFailure, startScanCamera]);
}, [cancelActiveScan, elementId, enabled, formatsToSupport, onCameraStartFailure, onScanFailure, startScanCamera]);
return {
startScanFile,