@nocobase/client-v2 2.4.0-alpha.5 → 2.4.0-alpha.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/es/components/form/ScanInput/useCodeScanner.d.ts +12 -2
- package/es/components/form/ScanInput/zxingWasmDecoder.d.ts +9 -0
- package/es/flow/components/FieldAssignValueInput.d.ts +2 -0
- package/es/flow/components/field-value-variable/FieldValueVariableInput.d.ts +1 -0
- package/es/flow/components/filter/VariableFilterItem.d.ts +7 -0
- package/es/index.mjs +20 -20
- package/lib/index.js +111 -111
- package/package.json +9 -8
- package/src/components/form/ScanInput/CodeScanner.tsx +3 -1
- package/src/components/form/ScanInput/__tests__/CodeScanner.test.tsx +7 -1
- package/src/components/form/ScanInput/__tests__/useCodeScanner.test.tsx +182 -8
- package/src/components/form/ScanInput/__tests__/zxingWasmDecoder.test.ts +78 -0
- package/src/components/form/ScanInput/useCodeScanner.ts +135 -20
- package/src/components/form/ScanInput/zxingWasmDecoder.ts +64 -0
- package/src/flow/actions/__tests__/validation.test.ts +48 -0
- package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx +39 -0
- package/src/flow/components/FieldAssignValueInput.tsx +4 -0
- package/src/flow/components/field-value-variable/FieldValueVariableInput.tsx +21 -12
- package/src/flow/components/field-value-variable/__tests__/FieldValueVariableInput.test.tsx +9 -0
- package/src/flow/components/filter/VariableFilterItem.tsx +11 -3
- package/src/flow/components/filter/__tests__/VariableFilterItem.rightMetaTree.test.tsx +158 -0
- package/src/flow/models/base/GridModel.tsx +0 -1
- package/src/flow/models/blocks/assign-form/AssignFormGridModel.tsx +22 -1
- package/src/flow/models/blocks/assign-form/AssignFormItemModel.tsx +14 -3
- package/src/flow/models/blocks/assign-form/__tests__/assignFieldValuesFlow.editor.test.tsx +140 -0
- package/src/flow/models/blocks/filter-form/__tests__/FilterFormGridModel.toggleFormFieldsCollapse.test.ts +29 -0
- package/src/flow/models/blocks/filter-form/fields/FieldComponentProps.tsx +1 -1
- package/src/flow/models/blocks/filter-form/fields/__tests__/FieldComponentProps.options.test.tsx +61 -0
- package/src/flow/models/blocks/form/__tests__/popupLinkage.test.tsx +175 -0
- package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +4 -3
- package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableField.tsx +5 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nocobase/client-v2",
|
|
3
|
-
"version": "2.4.0-alpha.
|
|
3
|
+
"version": "2.4.0-alpha.7",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"main": "lib/index.js",
|
|
6
6
|
"module": "es/index.mjs",
|
|
@@ -27,11 +27,11 @@
|
|
|
27
27
|
"@formily/antd-v5": "1.2.3",
|
|
28
28
|
"@formily/react": "^2.2.27",
|
|
29
29
|
"@formily/shared": "^2.2.27",
|
|
30
|
-
"@nocobase/evaluators": "2.4.0-alpha.
|
|
31
|
-
"@nocobase/flow-engine": "2.4.0-alpha.
|
|
32
|
-
"@nocobase/sdk": "2.4.0-alpha.
|
|
33
|
-
"@nocobase/shared": "2.4.0-alpha.
|
|
34
|
-
"@nocobase/utils": "2.4.0-alpha.
|
|
30
|
+
"@nocobase/evaluators": "2.4.0-alpha.7",
|
|
31
|
+
"@nocobase/flow-engine": "2.4.0-alpha.7",
|
|
32
|
+
"@nocobase/sdk": "2.4.0-alpha.7",
|
|
33
|
+
"@nocobase/shared": "2.4.0-alpha.7",
|
|
34
|
+
"@nocobase/utils": "2.4.0-alpha.7",
|
|
35
35
|
"ahooks": "^3.7.2",
|
|
36
36
|
"antd": "5.24.2",
|
|
37
37
|
"antd-style": "3.7.1",
|
|
@@ -46,7 +46,8 @@
|
|
|
46
46
|
"lodash": "4.17.21",
|
|
47
47
|
"react-device-detect": "2.2.3",
|
|
48
48
|
"react-i18next": "^11.15.1",
|
|
49
|
-
"react-router-dom": "^6.30.1"
|
|
49
|
+
"react-router-dom": "^6.30.1",
|
|
50
|
+
"zxing-wasm": "^3.1.3"
|
|
50
51
|
},
|
|
51
|
-
"gitHead": "
|
|
52
|
+
"gitHead": "bb018bfc8bdcc0f399244d2f248c55e51ceb2d1e"
|
|
52
53
|
}
|
|
@@ -41,6 +41,7 @@ function CodeScannerContent({ visible, formatsToSupport, onClose, onScanSuccess
|
|
|
41
41
|
const inputId = useId().replace(/:/g, '');
|
|
42
42
|
const scannerElementId = `code-scanner-${inputId}`;
|
|
43
43
|
const imgUploaderRef = useRef<HTMLInputElement>(null);
|
|
44
|
+
const scannerViewportRef = useRef<HTMLDivElement>(null);
|
|
44
45
|
const [cameraAvailable, setCameraAvailable] = useState(false);
|
|
45
46
|
const [scannerSize, setScannerSize] = useState({ width: 0, height: 0 });
|
|
46
47
|
const [viewport, setViewport] = useState(getViewportSize);
|
|
@@ -84,6 +85,7 @@ function CodeScannerContent({ visible, formatsToSupport, onClose, onScanSuccess
|
|
|
84
85
|
elementId: scannerElementId,
|
|
85
86
|
formatsToSupport,
|
|
86
87
|
onScannerSizeChanged: setScannerSize,
|
|
88
|
+
scanViewportRef: scannerViewportRef,
|
|
87
89
|
onScanSuccess: handleScanSuccess,
|
|
88
90
|
onScanFailure: showScanFailure,
|
|
89
91
|
onCameraStartFailure: showCameraStartFailure,
|
|
@@ -207,7 +209,7 @@ function CodeScannerContent({ visible, formatsToSupport, onClose, onScanSuccess
|
|
|
207
209
|
|
|
208
210
|
return (
|
|
209
211
|
<div className={rootClass}>
|
|
210
|
-
<div className={scannerWrapperClass}>
|
|
212
|
+
<div ref={scannerViewportRef} className={scannerWrapperClass}>
|
|
211
213
|
<div
|
|
212
214
|
className={scannerSurfaceClass}
|
|
213
215
|
style={{
|
|
@@ -14,6 +14,7 @@ import { CodeScanner } from '../CodeScanner';
|
|
|
14
14
|
|
|
15
15
|
const mocks = vi.hoisted(() => ({
|
|
16
16
|
getCameras: vi.fn().mockResolvedValue([{ id: 'rear-camera' }]),
|
|
17
|
+
scanViewport: null as HTMLElement | null,
|
|
17
18
|
}));
|
|
18
19
|
|
|
19
20
|
vi.mock('html5-qrcode', () => ({
|
|
@@ -53,15 +54,18 @@ vi.mock('../useCodeScanner', async (importOriginal) => {
|
|
|
53
54
|
useCodeScanner: ({
|
|
54
55
|
enabled,
|
|
55
56
|
onScannerSizeChanged,
|
|
57
|
+
scanViewportRef,
|
|
56
58
|
}: {
|
|
57
59
|
enabled: boolean;
|
|
58
60
|
onScannerSizeChanged?: (size: { width: number; height: number }) => void;
|
|
61
|
+
scanViewportRef?: { current: HTMLElement | null };
|
|
59
62
|
}) => {
|
|
60
63
|
ReactModule.useEffect(() => {
|
|
61
64
|
if (enabled) {
|
|
65
|
+
mocks.scanViewport = scanViewportRef?.current || null;
|
|
62
66
|
onScannerSizeChanged?.({ width: 1280, height: 720 });
|
|
63
67
|
}
|
|
64
|
-
}, [enabled, onScannerSizeChanged]);
|
|
68
|
+
}, [enabled, onScannerSizeChanged, scanViewportRef]);
|
|
65
69
|
|
|
66
70
|
return { startScanFile: vi.fn() };
|
|
67
71
|
},
|
|
@@ -70,6 +74,7 @@ vi.mock('../useCodeScanner', async (importOriginal) => {
|
|
|
70
74
|
|
|
71
75
|
describe('CodeScanner', () => {
|
|
72
76
|
afterEach(() => {
|
|
77
|
+
mocks.scanViewport = null;
|
|
73
78
|
vi.restoreAllMocks();
|
|
74
79
|
});
|
|
75
80
|
|
|
@@ -82,6 +87,7 @@ describe('CodeScanner', () => {
|
|
|
82
87
|
render(<CodeScanner visible onClose={() => undefined} onScanSuccess={() => undefined} />);
|
|
83
88
|
|
|
84
89
|
await waitFor(() => expect(document.getElementById('code-scan-box')).toBeInTheDocument());
|
|
90
|
+
expect(mocks.scanViewport).toBeInstanceOf(HTMLElement);
|
|
85
91
|
|
|
86
92
|
const scannerElement = document.querySelector<HTMLElement>('[id^="code-scanner-"]');
|
|
87
93
|
const scannerSurface = scannerElement?.parentElement;
|
|
@@ -10,7 +10,14 @@
|
|
|
10
10
|
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
11
11
|
import React, { useCallback } from 'react';
|
|
12
12
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
DEFAULT_CODE_FORMATS,
|
|
15
|
+
getCodeScanBoxSize,
|
|
16
|
+
isIOSBrowser,
|
|
17
|
+
scanQrVideoFrame,
|
|
18
|
+
scanQrVideoFrameWithZxingWasm,
|
|
19
|
+
useCodeScanner,
|
|
20
|
+
} from '../useCodeScanner';
|
|
14
21
|
|
|
15
22
|
type MockScannerInstance = {
|
|
16
23
|
applyVideoConstraints: ReturnType<typeof vi.fn>;
|
|
@@ -58,6 +65,10 @@ const jsQrMocks = vi.hoisted(() => {
|
|
|
58
65
|
};
|
|
59
66
|
});
|
|
60
67
|
|
|
68
|
+
const zxingWasmMocks = vi.hoisted(() => ({
|
|
69
|
+
decodeQrCodeWithZxingWasm: vi.fn(),
|
|
70
|
+
}));
|
|
71
|
+
|
|
61
72
|
vi.mock('html5-qrcode', () => ({
|
|
62
73
|
Html5Qrcode: mocks.Html5Qrcode,
|
|
63
74
|
Html5QrcodeScannerState: {
|
|
@@ -81,6 +92,7 @@ vi.mock('html5-qrcode', () => ({
|
|
|
81
92
|
}));
|
|
82
93
|
|
|
83
94
|
vi.mock('jsqr', () => jsQrMocks);
|
|
95
|
+
vi.mock('../zxingWasmDecoder', () => zxingWasmMocks);
|
|
84
96
|
|
|
85
97
|
function ScannerHost({
|
|
86
98
|
onCameraStartFailure,
|
|
@@ -148,19 +160,71 @@ function stubImageElement(size: { width: number; height: number } = { width: 600
|
|
|
148
160
|
});
|
|
149
161
|
}
|
|
150
162
|
|
|
151
|
-
function stubCanvas() {
|
|
163
|
+
function stubCanvas(imageData?: ImageData) {
|
|
152
164
|
const canvasContext = {
|
|
153
165
|
drawImage: vi.fn(),
|
|
154
|
-
getImageData: vi.fn(
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
166
|
+
getImageData: vi.fn(
|
|
167
|
+
(_x: number, _y: number, width: number, height: number) =>
|
|
168
|
+
imageData || {
|
|
169
|
+
data: new Uint8ClampedArray(width * height * 4),
|
|
170
|
+
height,
|
|
171
|
+
width,
|
|
172
|
+
},
|
|
173
|
+
),
|
|
159
174
|
} as unknown as CanvasRenderingContext2D;
|
|
160
175
|
|
|
161
176
|
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(canvasContext);
|
|
162
177
|
}
|
|
163
178
|
|
|
179
|
+
function createCustomerQrFrame(width: number, height: number) {
|
|
180
|
+
const modules = [
|
|
181
|
+
'111111101101001111111',
|
|
182
|
+
'100000100101101000001',
|
|
183
|
+
'101110100111101011101',
|
|
184
|
+
'101110100111101011101',
|
|
185
|
+
'101110101100101011101',
|
|
186
|
+
'100000101000101000001',
|
|
187
|
+
'111111101010101111111',
|
|
188
|
+
'000000000111100000000',
|
|
189
|
+
'011111110001100110001',
|
|
190
|
+
'011010001101101111110',
|
|
191
|
+
'101101110010011000110',
|
|
192
|
+
'011000001011010110101',
|
|
193
|
+
'011110111011000100100',
|
|
194
|
+
'000000001100100001010',
|
|
195
|
+
'111111101001100101110',
|
|
196
|
+
'100000101001100001101',
|
|
197
|
+
'101110101010000101110',
|
|
198
|
+
'101110101011111001000',
|
|
199
|
+
'101110101110101011000',
|
|
200
|
+
'100000101101111010001',
|
|
201
|
+
'111111100110100010000',
|
|
202
|
+
];
|
|
203
|
+
const data = new Uint8ClampedArray(width * height * 4);
|
|
204
|
+
data.fill(255);
|
|
205
|
+
const moduleSize = 12;
|
|
206
|
+
const left = Math.floor((width - modules.length * moduleSize) / 2);
|
|
207
|
+
const top = 60;
|
|
208
|
+
|
|
209
|
+
modules.forEach((row, rowIndex) => {
|
|
210
|
+
Array.from(row).forEach((module, columnIndex) => {
|
|
211
|
+
if (module !== '1') {
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
for (let y = 0; y < moduleSize; y += 1) {
|
|
215
|
+
for (let x = 0; x < moduleSize; x += 1) {
|
|
216
|
+
const pixelIndex = ((top + rowIndex * moduleSize + y) * width + left + columnIndex * moduleSize + x) * 4;
|
|
217
|
+
data[pixelIndex] = 0;
|
|
218
|
+
data[pixelIndex + 1] = 0;
|
|
219
|
+
data[pixelIndex + 2] = 0;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
return { data, height, width } as ImageData;
|
|
226
|
+
}
|
|
227
|
+
|
|
164
228
|
describe('useCodeScanner', () => {
|
|
165
229
|
beforeEach(() => {
|
|
166
230
|
vi.clearAllMocks();
|
|
@@ -170,6 +234,7 @@ describe('useCodeScanner', () => {
|
|
|
170
234
|
mocks.stop.mockResolvedValue(null);
|
|
171
235
|
mocks.getState.mockReturnValue(1);
|
|
172
236
|
jsQrMocks.default.mockReturnValue(undefined);
|
|
237
|
+
zxingWasmMocks.decodeQrCodeWithZxingWasm.mockResolvedValue(undefined);
|
|
173
238
|
});
|
|
174
239
|
|
|
175
240
|
afterEach(() => {
|
|
@@ -233,7 +298,7 @@ describe('useCodeScanner', () => {
|
|
|
233
298
|
});
|
|
234
299
|
});
|
|
235
300
|
|
|
236
|
-
it('
|
|
301
|
+
it('keeps the existing centered scan region outside iOS', () => {
|
|
237
302
|
jsQrMocks.default.mockReturnValueOnce({ data: 'FAST-QR' });
|
|
238
303
|
stubCanvas();
|
|
239
304
|
const video = document.createElement('video');
|
|
@@ -255,6 +320,115 @@ describe('useCodeScanner', () => {
|
|
|
255
320
|
});
|
|
256
321
|
});
|
|
257
322
|
|
|
323
|
+
it('decodes the camera preview visible on iOS without scanning offscreen content', async () => {
|
|
324
|
+
const actualJsQr = await vi.importActual<typeof import('jsqr')>('jsqr');
|
|
325
|
+
jsQrMocks.default.mockImplementationOnce(actualJsQr.default);
|
|
326
|
+
stubCanvas(createCustomerQrFrame(431, 933));
|
|
327
|
+
const video = document.createElement('video');
|
|
328
|
+
Object.defineProperties(video, {
|
|
329
|
+
getBoundingClientRect: {
|
|
330
|
+
value: () => ({ bottom: 864, height: 864, left: -573, right: 963, top: 0, width: 1536 }),
|
|
331
|
+
},
|
|
332
|
+
readyState: { value: HTMLMediaElement.HAVE_CURRENT_DATA },
|
|
333
|
+
videoHeight: { value: 1080 },
|
|
334
|
+
videoWidth: { value: 1920 },
|
|
335
|
+
});
|
|
336
|
+
const scanViewport = document.createElement('div');
|
|
337
|
+
vi.spyOn(scanViewport, 'getBoundingClientRect').mockReturnValue({
|
|
338
|
+
bottom: 844,
|
|
339
|
+
height: 844,
|
|
340
|
+
left: 0,
|
|
341
|
+
right: 390,
|
|
342
|
+
top: 0,
|
|
343
|
+
width: 390,
|
|
344
|
+
x: 0,
|
|
345
|
+
y: 0,
|
|
346
|
+
toJSON: () => undefined,
|
|
347
|
+
});
|
|
348
|
+
const canvas = document.createElement('canvas');
|
|
349
|
+
|
|
350
|
+
expect(scanQrVideoFrame(video, canvas, scanViewport)).toBe('TCBNVBY064J3');
|
|
351
|
+
|
|
352
|
+
const context = canvas.getContext('2d');
|
|
353
|
+
expect(context?.drawImage).toHaveBeenCalledWith(video, 716, 0, 488, 1055, 0, 0, 431, 933);
|
|
354
|
+
expect(jsQrMocks.default).toHaveBeenCalledWith(expect.any(Uint8ClampedArray), 431, 933, {
|
|
355
|
+
inversionAttempts: 'dontInvert',
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it('uses ZXing WASM for high-resolution iOS camera frames', async () => {
|
|
360
|
+
const imageData = createCustomerQrFrame(431, 933);
|
|
361
|
+
stubCanvas(imageData);
|
|
362
|
+
zxingWasmMocks.decodeQrCodeWithZxingWasm.mockResolvedValueOnce('CUSTOMER-QR');
|
|
363
|
+
const video = document.createElement('video');
|
|
364
|
+
Object.defineProperties(video, {
|
|
365
|
+
getBoundingClientRect: {
|
|
366
|
+
value: () => ({ bottom: 864, height: 864, left: -573, right: 963, top: 0, width: 1536 }),
|
|
367
|
+
},
|
|
368
|
+
readyState: { value: HTMLMediaElement.HAVE_CURRENT_DATA },
|
|
369
|
+
videoHeight: { value: 1080 },
|
|
370
|
+
videoWidth: { value: 1920 },
|
|
371
|
+
});
|
|
372
|
+
const scanViewport = document.createElement('div');
|
|
373
|
+
vi.spyOn(scanViewport, 'getBoundingClientRect').mockReturnValue({
|
|
374
|
+
bottom: 844,
|
|
375
|
+
height: 844,
|
|
376
|
+
left: 0,
|
|
377
|
+
right: 390,
|
|
378
|
+
top: 0,
|
|
379
|
+
width: 390,
|
|
380
|
+
x: 0,
|
|
381
|
+
y: 0,
|
|
382
|
+
toJSON: () => undefined,
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
await expect(scanQrVideoFrameWithZxingWasm(video, document.createElement('canvas'), scanViewport)).resolves.toBe(
|
|
386
|
+
'CUSTOMER-QR',
|
|
387
|
+
);
|
|
388
|
+
const context = document.createElement('canvas').getContext('2d');
|
|
389
|
+
expect(context?.drawImage).toHaveBeenCalledWith(video, 716, 0, 488, 1055, 0, 0, 488, 1055);
|
|
390
|
+
expect(jsQrMocks.default).not.toHaveBeenCalled();
|
|
391
|
+
expect(zxingWasmMocks.decodeQrCodeWithZxingWasm).toHaveBeenCalledWith(imageData);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it('detects iPhone browsers', () => {
|
|
395
|
+
vi.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (iPhone; CPU iPhone OS 18_0)');
|
|
396
|
+
expect(isIOSBrowser()).toBe(true);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it('detects iPad browsers using desktop mode', () => {
|
|
400
|
+
vi.stubGlobal('navigator', {
|
|
401
|
+
maxTouchPoints: 5,
|
|
402
|
+
platform: 'MacIntel',
|
|
403
|
+
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15)',
|
|
404
|
+
});
|
|
405
|
+
expect(isIOSBrowser()).toBe(true);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it('keeps the iOS preview path disabled on other platforms', () => {
|
|
409
|
+
vi.stubGlobal('navigator', {
|
|
410
|
+
maxTouchPoints: 0,
|
|
411
|
+
platform: 'Linux x86_64',
|
|
412
|
+
userAgent: 'Mozilla/5.0 (X11; Linux x86_64)',
|
|
413
|
+
});
|
|
414
|
+
expect(isIOSBrowser()).toBe(false);
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it('waits for a measurable iOS preview instead of scanning hidden camera content', () => {
|
|
418
|
+
stubCanvas();
|
|
419
|
+
const video = document.createElement('video');
|
|
420
|
+
Object.defineProperties(video, {
|
|
421
|
+
readyState: { value: HTMLMediaElement.HAVE_CURRENT_DATA },
|
|
422
|
+
videoHeight: { value: 1080 },
|
|
423
|
+
videoWidth: { value: 1920 },
|
|
424
|
+
});
|
|
425
|
+
const canvas = document.createElement('canvas');
|
|
426
|
+
const scanViewport = document.createElement('div');
|
|
427
|
+
|
|
428
|
+
expect(scanQrVideoFrame(video, canvas, scanViewport)).toBeUndefined();
|
|
429
|
+
expect(jsQrMocks.default).not.toHaveBeenCalled();
|
|
430
|
+
});
|
|
431
|
+
|
|
258
432
|
it('enables continuous camera focus when the device supports it', async () => {
|
|
259
433
|
mocks.getRunningTrackCapabilities.mockReturnValue({ focusMode: ['manual', 'continuous'] });
|
|
260
434
|
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This file is part of the NocoBase (R) project.
|
|
3
|
+
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
|
4
|
+
* Authors: NocoBase Team.
|
|
5
|
+
*
|
|
6
|
+
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
|
7
|
+
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { beforeEach, expect, it, vi } from 'vitest';
|
|
11
|
+
import { decodeQrCodeWithZxingWasm } from '../zxingWasmDecoder';
|
|
12
|
+
|
|
13
|
+
const mocks = vi.hoisted(() => ({
|
|
14
|
+
prepareZXingModule: vi.fn(),
|
|
15
|
+
purgeZXingModule: vi.fn(),
|
|
16
|
+
readBarcodes: vi.fn(),
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
vi.mock('zxing-wasm/reader', () => mocks);
|
|
20
|
+
vi.mock('zxing-wasm/reader/zxing_reader.wasm', () => ({ default: '/assets/zxing_reader.wasm' }));
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
vi.clearAllMocks();
|
|
24
|
+
mocks.prepareZXingModule.mockResolvedValue(undefined);
|
|
25
|
+
mocks.readBarcodes.mockResolvedValue([]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('retries decoder loading and uses downscaled QR detection', async () => {
|
|
29
|
+
const imageData = { data: new Uint8ClampedArray(16), height: 2, width: 2 } as ImageData;
|
|
30
|
+
mocks.prepareZXingModule.mockRejectedValueOnce(new Error('decoder loading failed'));
|
|
31
|
+
|
|
32
|
+
await expect(decodeQrCodeWithZxingWasm(imageData)).rejects.toThrow('decoder loading failed');
|
|
33
|
+
expect(mocks.purgeZXingModule).toHaveBeenCalledTimes(1);
|
|
34
|
+
|
|
35
|
+
mocks.readBarcodes.mockResolvedValueOnce([{ text: 'CUSTOMER-QR' }]);
|
|
36
|
+
|
|
37
|
+
await expect(decodeQrCodeWithZxingWasm(imageData)).resolves.toBe('CUSTOMER-QR');
|
|
38
|
+
expect(mocks.prepareZXingModule).toHaveBeenCalledTimes(2);
|
|
39
|
+
const { overrides } = mocks.prepareZXingModule.mock.calls[1][0] as {
|
|
40
|
+
overrides: { locateFile: () => string };
|
|
41
|
+
};
|
|
42
|
+
const { locateFile } = overrides;
|
|
43
|
+
expect(locateFile()).toBe('/assets/zxing_reader.wasm');
|
|
44
|
+
expect(mocks.prepareZXingModule).toHaveBeenLastCalledWith({
|
|
45
|
+
fireImmediately: true,
|
|
46
|
+
overrides: { locateFile: expect.any(Function) },
|
|
47
|
+
});
|
|
48
|
+
expect(mocks.readBarcodes).toHaveBeenCalledWith(imageData, {
|
|
49
|
+
binarizer: 'GlobalHistogram',
|
|
50
|
+
downscaleThreshold: 300,
|
|
51
|
+
formats: ['QRCode'],
|
|
52
|
+
maxNumberOfSymbols: 1,
|
|
53
|
+
tryDenoise: true,
|
|
54
|
+
tryDownscale: true,
|
|
55
|
+
tryHarder: true,
|
|
56
|
+
tryInvert: true,
|
|
57
|
+
tryRotate: true,
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('retries reflective frames with local adaptive binarization', async () => {
|
|
62
|
+
const imageData = { data: new Uint8ClampedArray(16), height: 2, width: 2 } as ImageData;
|
|
63
|
+
mocks.readBarcodes.mockResolvedValueOnce([]).mockResolvedValueOnce([{ text: 'REFLECTIVE-QR' }]);
|
|
64
|
+
|
|
65
|
+
await expect(decodeQrCodeWithZxingWasm(imageData)).resolves.toBe('REFLECTIVE-QR');
|
|
66
|
+
expect(mocks.readBarcodes).toHaveBeenCalledTimes(2);
|
|
67
|
+
expect(mocks.readBarcodes).toHaveBeenNthCalledWith(2, imageData, {
|
|
68
|
+
binarizer: 'LocalAverage',
|
|
69
|
+
downscaleThreshold: 300,
|
|
70
|
+
formats: ['QRCode'],
|
|
71
|
+
maxNumberOfSymbols: 1,
|
|
72
|
+
tryDenoise: true,
|
|
73
|
+
tryDownscale: true,
|
|
74
|
+
tryHarder: true,
|
|
75
|
+
tryInvert: true,
|
|
76
|
+
tryRotate: true,
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
import { Html5Qrcode, Html5QrcodeScannerState, Html5QrcodeSupportedFormats } from 'html5-qrcode';
|
|
11
11
|
import jsQR from 'jsqr';
|
|
12
12
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
13
|
+
import type { RefObject } from 'react';
|
|
13
14
|
import type { CodeFormatsToSupport } from './types';
|
|
15
|
+
import { decodeQrCodeWithZxingWasm } from './zxingWasmDecoder';
|
|
14
16
|
|
|
15
17
|
type ScannerSize = {
|
|
16
18
|
width: number;
|
|
@@ -22,6 +24,7 @@ type UseCodeScannerOptions = {
|
|
|
22
24
|
elementId: string;
|
|
23
25
|
formatsToSupport?: CodeFormatsToSupport;
|
|
24
26
|
onScannerSizeChanged?: (size: ScannerSize) => void;
|
|
27
|
+
scanViewportRef?: RefObject<HTMLElement | null>;
|
|
25
28
|
onScanSuccess: (text: string) => void;
|
|
26
29
|
onScanFailure?: () => void;
|
|
27
30
|
onCameraStartFailure?: (error: unknown) => void;
|
|
@@ -37,6 +40,12 @@ type JsQRImageTransform = {
|
|
|
37
40
|
threshold?: number;
|
|
38
41
|
};
|
|
39
42
|
|
|
43
|
+
type QrFrameCaptureLimits = {
|
|
44
|
+
maxHeight: number;
|
|
45
|
+
maxPixels: number;
|
|
46
|
+
maxWidth: number;
|
|
47
|
+
};
|
|
48
|
+
|
|
40
49
|
type FocusMediaTrackCapabilities = MediaTrackCapabilities & {
|
|
41
50
|
focusMode?: string[];
|
|
42
51
|
};
|
|
@@ -47,8 +56,15 @@ type FocusMediaTrackConstraintSet = MediaTrackConstraintSet & {
|
|
|
47
56
|
|
|
48
57
|
const QR_SCAN_IMAGE_SIZES = [3200, 2400, 1600, 1000];
|
|
49
58
|
const LIVE_QR_SCAN_INTERVAL = 120;
|
|
59
|
+
const IOS_ZXING_SCAN_INTERVAL = 200;
|
|
50
60
|
const LIVE_QR_SCAN_MAX_WIDTH = 960;
|
|
51
61
|
const LIVE_QR_SCAN_MAX_HEIGHT = 540;
|
|
62
|
+
const LIVE_QR_SCAN_MAX_PIXELS = LIVE_QR_SCAN_MAX_WIDTH * 420;
|
|
63
|
+
const IOS_ZXING_SCAN_LIMITS: QrFrameCaptureLimits = {
|
|
64
|
+
maxHeight: 720,
|
|
65
|
+
maxPixels: 720 * 960,
|
|
66
|
+
maxWidth: 1280,
|
|
67
|
+
};
|
|
52
68
|
const QR_SCAN_IMAGE_TRANSFORMS: JsQRImageTransform[] = [
|
|
53
69
|
{},
|
|
54
70
|
{ contrast: 3, threshold: 105 },
|
|
@@ -81,7 +97,49 @@ export function getCodeScanBoxSize(width: number, height: number) {
|
|
|
81
97
|
};
|
|
82
98
|
}
|
|
83
99
|
|
|
84
|
-
export function
|
|
100
|
+
export function isIOSBrowser() {
|
|
101
|
+
return (
|
|
102
|
+
/iPad|iPhone|iPod/i.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function getVisibleVideoFrameRegion(video: HTMLVideoElement, scanViewport: Element) {
|
|
107
|
+
const rect = video.getBoundingClientRect();
|
|
108
|
+
const viewportRect = scanViewport.getBoundingClientRect();
|
|
109
|
+
const visibleLeft = Math.max(viewportRect.left, rect.left);
|
|
110
|
+
const visibleTop = Math.max(viewportRect.top, rect.top);
|
|
111
|
+
const visibleRight = Math.min(viewportRect.right, rect.right);
|
|
112
|
+
const visibleBottom = Math.min(viewportRect.bottom, rect.bottom);
|
|
113
|
+
if (!rect.width || !rect.height || visibleRight <= visibleLeft || visibleBottom <= visibleTop) {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const sourceX = Math.max(0, Math.floor(((visibleLeft - rect.left) / rect.width) * video.videoWidth));
|
|
118
|
+
const sourceY = Math.max(0, Math.floor(((visibleTop - rect.top) / rect.height) * video.videoHeight));
|
|
119
|
+
return {
|
|
120
|
+
x: sourceX,
|
|
121
|
+
y: sourceY,
|
|
122
|
+
width: Math.min(
|
|
123
|
+
video.videoWidth - sourceX,
|
|
124
|
+
Math.ceil(((visibleRight - visibleLeft) / rect.width) * video.videoWidth),
|
|
125
|
+
),
|
|
126
|
+
height: Math.min(
|
|
127
|
+
video.videoHeight - sourceY,
|
|
128
|
+
Math.ceil(((visibleBottom - visibleTop) / rect.height) * video.videoHeight),
|
|
129
|
+
),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function getQrVideoFrameImageData(
|
|
134
|
+
video: HTMLVideoElement,
|
|
135
|
+
canvas: HTMLCanvasElement,
|
|
136
|
+
scanViewport?: Element,
|
|
137
|
+
limits: QrFrameCaptureLimits = {
|
|
138
|
+
maxHeight: LIVE_QR_SCAN_MAX_HEIGHT,
|
|
139
|
+
maxPixels: LIVE_QR_SCAN_MAX_PIXELS,
|
|
140
|
+
maxWidth: LIVE_QR_SCAN_MAX_WIDTH,
|
|
141
|
+
},
|
|
142
|
+
) {
|
|
85
143
|
if (!video.videoWidth || !video.videoHeight || video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) {
|
|
86
144
|
return;
|
|
87
145
|
}
|
|
@@ -89,16 +147,27 @@ export function scanQrVideoFrame(video: HTMLVideoElement, canvas: HTMLCanvasElem
|
|
|
89
147
|
const viewfinderWidth = video.clientWidth || video.videoWidth;
|
|
90
148
|
const viewfinderHeight = video.clientHeight || video.videoHeight;
|
|
91
149
|
const scanBoxSize = getCodeScanBoxSize(viewfinderWidth, viewfinderHeight);
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
video.videoHeight,
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
150
|
+
const scanBoxRegion = {
|
|
151
|
+
width: Math.min(video.videoWidth, Math.floor(scanBoxSize.width * (video.videoWidth / viewfinderWidth))),
|
|
152
|
+
height: Math.min(video.videoHeight, Math.floor(scanBoxSize.height * (video.videoHeight / viewfinderHeight))),
|
|
153
|
+
};
|
|
154
|
+
const sourceRegion = scanViewport
|
|
155
|
+
? getVisibleVideoFrameRegion(video, scanViewport)
|
|
156
|
+
: {
|
|
157
|
+
x: Math.floor((video.videoWidth - scanBoxRegion.width) / 2),
|
|
158
|
+
y: Math.floor((video.videoHeight - scanBoxRegion.height) / 2),
|
|
159
|
+
...scanBoxRegion,
|
|
160
|
+
};
|
|
161
|
+
if (!sourceRegion) {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const maxWidth = scanViewport && sourceRegion.height > sourceRegion.width ? limits.maxHeight : limits.maxWidth;
|
|
166
|
+
const maxHeight = scanViewport && sourceRegion.height > sourceRegion.width ? limits.maxWidth : limits.maxHeight;
|
|
167
|
+
const pixelScale = scanViewport ? Math.sqrt(limits.maxPixels / (sourceRegion.width * sourceRegion.height)) : 1;
|
|
168
|
+
const targetScale = Math.min(1, maxWidth / sourceRegion.width, maxHeight / sourceRegion.height, pixelScale);
|
|
169
|
+
const targetWidth = Math.max(1, Math.floor(sourceRegion.width * targetScale));
|
|
170
|
+
const targetHeight = Math.max(1, Math.floor(sourceRegion.height * targetScale));
|
|
102
171
|
if (canvas.width !== targetWidth) {
|
|
103
172
|
canvas.width = targetWidth;
|
|
104
173
|
}
|
|
@@ -110,30 +179,75 @@ export function scanQrVideoFrame(video: HTMLVideoElement, canvas: HTMLCanvasElem
|
|
|
110
179
|
return;
|
|
111
180
|
}
|
|
112
181
|
|
|
113
|
-
context.drawImage(
|
|
114
|
-
|
|
182
|
+
context.drawImage(
|
|
183
|
+
video,
|
|
184
|
+
sourceRegion.x,
|
|
185
|
+
sourceRegion.y,
|
|
186
|
+
sourceRegion.width,
|
|
187
|
+
sourceRegion.height,
|
|
188
|
+
0,
|
|
189
|
+
0,
|
|
190
|
+
targetWidth,
|
|
191
|
+
targetHeight,
|
|
192
|
+
);
|
|
193
|
+
return context.getImageData(0, 0, targetWidth, targetHeight);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function scanQrVideoFrame(video: HTMLVideoElement, canvas: HTMLCanvasElement, scanViewport?: Element) {
|
|
197
|
+
const imageData = getQrVideoFrameImageData(video, canvas, scanViewport);
|
|
198
|
+
if (!imageData) {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
115
201
|
return jsQR(imageData.data, imageData.width, imageData.height, { inversionAttempts: 'dontInvert' })?.data;
|
|
116
202
|
}
|
|
117
203
|
|
|
118
|
-
|
|
204
|
+
export async function scanQrVideoFrameWithZxingWasm(
|
|
205
|
+
video: HTMLVideoElement,
|
|
206
|
+
canvas: HTMLCanvasElement,
|
|
207
|
+
scanViewport: Element,
|
|
208
|
+
) {
|
|
209
|
+
const imageData = getQrVideoFrameImageData(video, canvas, scanViewport, IOS_ZXING_SCAN_LIMITS);
|
|
210
|
+
if (!imageData) {
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
return decodeQrCodeWithZxingWasm(imageData);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function startLiveQrScan(
|
|
217
|
+
elementId: string,
|
|
218
|
+
onScanSuccess: (text: string) => void,
|
|
219
|
+
scanViewportRef?: RefObject<HTMLElement | null>,
|
|
220
|
+
) {
|
|
119
221
|
const canvas = document.createElement('canvas');
|
|
222
|
+
const useVisiblePreview = isIOSBrowser();
|
|
120
223
|
let timer: number | undefined;
|
|
121
224
|
let stopped = false;
|
|
122
225
|
|
|
123
|
-
const scan = () => {
|
|
226
|
+
const scan = async () => {
|
|
124
227
|
if (stopped) {
|
|
125
228
|
return;
|
|
126
229
|
}
|
|
127
230
|
const video = document.getElementById(elementId)?.querySelector('video');
|
|
231
|
+
const scanViewport = useVisiblePreview ? scanViewportRef?.current : undefined;
|
|
128
232
|
if (video) {
|
|
129
|
-
|
|
130
|
-
|
|
233
|
+
let decodedText: string | undefined;
|
|
234
|
+
try {
|
|
235
|
+
decodedText =
|
|
236
|
+
useVisiblePreview && scanViewport
|
|
237
|
+
? await scanQrVideoFrameWithZxingWasm(video, canvas, scanViewport)
|
|
238
|
+
: scanQrVideoFrame(video, canvas);
|
|
239
|
+
} catch {
|
|
240
|
+
// Keep the existing scanners active when the optional WASM decoder cannot load or decode a frame.
|
|
241
|
+
}
|
|
242
|
+
if (decodedText && !stopped) {
|
|
131
243
|
stopped = true;
|
|
132
244
|
onScanSuccess(decodedText);
|
|
133
245
|
return;
|
|
134
246
|
}
|
|
135
247
|
}
|
|
136
|
-
|
|
248
|
+
if (!stopped) {
|
|
249
|
+
timer = window.setTimeout(scan, useVisiblePreview ? IOS_ZXING_SCAN_INTERVAL : LIVE_QR_SCAN_INTERVAL);
|
|
250
|
+
}
|
|
137
251
|
};
|
|
138
252
|
|
|
139
253
|
timer = window.setTimeout(scan, 0);
|
|
@@ -304,6 +418,7 @@ export function useCodeScanner({
|
|
|
304
418
|
elementId,
|
|
305
419
|
formatsToSupport,
|
|
306
420
|
onScannerSizeChanged,
|
|
421
|
+
scanViewportRef,
|
|
307
422
|
onScanSuccess,
|
|
308
423
|
onScanFailure,
|
|
309
424
|
onCameraStartFailure,
|
|
@@ -377,11 +492,11 @@ export function useCodeScanner({
|
|
|
377
492
|
return;
|
|
378
493
|
}
|
|
379
494
|
if (shouldScanQrWithJsQR(formatsToSupport)) {
|
|
380
|
-
liveQrScanStopRef.current = startLiveQrScan(elementId, reportScanSuccess);
|
|
495
|
+
liveQrScanStopRef.current = startLiveQrScan(elementId, reportScanSuccess, scanViewportRef);
|
|
381
496
|
}
|
|
382
497
|
await enableContinuousFocus(scannerInstance);
|
|
383
498
|
},
|
|
384
|
-
[cancelActiveScan, elementId, formatsToSupport, onScannerSizeChanged, reportScanSuccess],
|
|
499
|
+
[cancelActiveScan, elementId, formatsToSupport, onScannerSizeChanged, reportScanSuccess, scanViewportRef],
|
|
385
500
|
);
|
|
386
501
|
|
|
387
502
|
const startScanFile = useCallback(
|