mirror of
https://github.com/nocobase/nocobase.git
synced 2026-08-29 02:03:53 +08:00
Merge branch 'main' into next
This commit is contained in:
@@ -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' });
|
||||
});
|
||||
});
|
||||
});
|
||||
+114
-18
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user