@nocobase/client-v2 2.1.22 → 2.1.24
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/BaseApplication.d.ts +1 -1
- package/es/components/form/ScanInput/useCodeScanner.d.ts +2 -1
- package/es/flow-compat/fieldValidationConstants.d.ts +1 -1
- package/es/index.mjs +93 -92
- package/lib/index.js +103 -102
- package/package.json +8 -7
- package/src/BaseApplication.tsx +8 -7
- package/src/__tests__/app.test.tsx +55 -0
- package/src/collection-manager/field-validation.ts +1 -1
- package/src/components/form/ScanInput/CodeScanner.tsx +23 -6
- package/src/components/form/ScanInput/__tests__/useCodeScanner.test.tsx +180 -3
- package/src/components/form/ScanInput/useCodeScanner.ts +170 -5
- package/src/flow/actions/__tests__/linkageRulesRefresh.test.ts +7 -3
- package/src/flow/actions/linkageRulesRefresh.tsx +2 -6
- package/src/flow/models/blocks/table/TableActionsColumnModel.tsx +2 -1
- package/src/flow/models/blocks/table/TableBlockModel.tsx +1 -1
- package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +1 -1
- package/src/flow-compat/fieldValidationConstants.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nocobase/client-v2",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.24",
|
|
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.1.
|
|
31
|
-
"@nocobase/flow-engine": "2.1.
|
|
32
|
-
"@nocobase/sdk": "2.1.
|
|
33
|
-
"@nocobase/shared": "2.1.
|
|
34
|
-
"@nocobase/utils": "2.1.
|
|
30
|
+
"@nocobase/evaluators": "2.1.24",
|
|
31
|
+
"@nocobase/flow-engine": "2.1.24",
|
|
32
|
+
"@nocobase/sdk": "2.1.24",
|
|
33
|
+
"@nocobase/shared": "2.1.24",
|
|
34
|
+
"@nocobase/utils": "2.1.24",
|
|
35
35
|
"ahooks": "^3.7.2",
|
|
36
36
|
"antd": "5.24.2",
|
|
37
37
|
"antd-style": "3.7.1",
|
|
@@ -42,9 +42,10 @@
|
|
|
42
42
|
"html5-qrcode": "^2.3.8",
|
|
43
43
|
"i18next": "^22.4.9",
|
|
44
44
|
"json5": "^2.2.3",
|
|
45
|
+
"jsqr": "^1.4.0",
|
|
45
46
|
"lodash": "4.17.21",
|
|
46
47
|
"react-i18next": "^11.15.1",
|
|
47
48
|
"react-router-dom": "^6.30.1"
|
|
48
49
|
},
|
|
49
|
-
"gitHead": "
|
|
50
|
+
"gitHead": "5b5e5c5987d6e93dadf940e0605e830b09f84f79"
|
|
50
51
|
}
|
package/src/BaseApplication.tsx
CHANGED
|
@@ -34,6 +34,7 @@ import { SystemSettingsSource } from './flow/system-settings';
|
|
|
34
34
|
import { LayoutManager } from './layout-manager/LayoutManager';
|
|
35
35
|
import type { PluginClass, PluginManager, PluginType } from './PluginManager';
|
|
36
36
|
import { RouteRepository } from './RouteRepository';
|
|
37
|
+
import { stripModernClientPrefix } from './authRedirect';
|
|
37
38
|
import type {
|
|
38
39
|
ComponentTypeAndString,
|
|
39
40
|
RenderableComponentType,
|
|
@@ -80,6 +81,10 @@ const trimTrailingSlashes = (value: string) => {
|
|
|
80
81
|
return match ? value.slice(0, -match[0].length) : value;
|
|
81
82
|
};
|
|
82
83
|
|
|
84
|
+
const ensureTrailingSlash = (value: string) => {
|
|
85
|
+
return `${trimTrailingSlashes(value)}/`;
|
|
86
|
+
};
|
|
87
|
+
|
|
83
88
|
const isRenderableComponentType = (value: unknown): value is AnyComponent => isValidElementType(value);
|
|
84
89
|
|
|
85
90
|
export type DevDynamicImport = (packageName: string) => Promise<{ default: PluginClass }>;
|
|
@@ -396,7 +401,7 @@ export abstract class BaseApplication<
|
|
|
396
401
|
this.favicon = favicon || '';
|
|
397
402
|
}
|
|
398
403
|
|
|
399
|
-
const iconHref = this.favicon || '
|
|
404
|
+
const iconHref = this.favicon || this.getCdnUrl() + 'favicon/favicon.ico';
|
|
400
405
|
|
|
401
406
|
if (faviconLinkElement) {
|
|
402
407
|
faviconLinkElement.href = iconHref;
|
|
@@ -432,15 +437,11 @@ export abstract class BaseApplication<
|
|
|
432
437
|
}
|
|
433
438
|
|
|
434
439
|
getCdnUrl() {
|
|
435
|
-
return window['__webpack_public_path__'] || this.getPublicPath();
|
|
440
|
+
return ensureTrailingSlash(window['__webpack_public_path__'] || stripModernClientPrefix(this.getPublicPath()));
|
|
436
441
|
}
|
|
437
442
|
|
|
438
443
|
getPublicPath() {
|
|
439
|
-
|
|
440
|
-
if (!publicPath.endsWith('/')) {
|
|
441
|
-
publicPath += '/';
|
|
442
|
-
}
|
|
443
|
-
return publicPath;
|
|
444
|
+
return ensureTrailingSlash(this.options.publicPath || '/');
|
|
444
445
|
}
|
|
445
446
|
|
|
446
447
|
getApiUrl(pathname = '') {
|
|
@@ -34,6 +34,8 @@ describe('app', () => {
|
|
|
34
34
|
afterEach(() => {
|
|
35
35
|
document.querySelectorAll('link[rel="shortcut icon"]').forEach((node) => node.remove());
|
|
36
36
|
document.documentElement.removeAttribute('lang');
|
|
37
|
+
delete window['__webpack_public_path__'];
|
|
38
|
+
delete window['__nocobase_modern_client_prefix__'];
|
|
37
39
|
vi.restoreAllMocks();
|
|
38
40
|
});
|
|
39
41
|
|
|
@@ -58,6 +60,59 @@ describe('app', () => {
|
|
|
58
60
|
expect(app.jsonLogic.apply({ $testAlwaysTrue: [] })).toBe(true);
|
|
59
61
|
});
|
|
60
62
|
|
|
63
|
+
it('should normalize publicPath and webpack public path with a single trailing slash', () => {
|
|
64
|
+
const app = new Application({
|
|
65
|
+
router,
|
|
66
|
+
publicPath: '/admin//',
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
expect(app.getPublicPath()).toBe('/admin/');
|
|
70
|
+
|
|
71
|
+
window['__webpack_public_path__'] = '/cdn/assets///';
|
|
72
|
+
expect(app.getCdnUrl()).toBe('/cdn/assets/');
|
|
73
|
+
|
|
74
|
+
delete window['__webpack_public_path__'];
|
|
75
|
+
expect(app.getCdnUrl()).toBe('/admin/');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('should normalize webpack public path without a trailing slash', () => {
|
|
79
|
+
const app = new Application({
|
|
80
|
+
router,
|
|
81
|
+
publicPath: '/admin/',
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
window['__webpack_public_path__'] = '/cdn/assets';
|
|
85
|
+
expect(app.getCdnUrl()).toBe('/cdn/assets/');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('should remove the modern client prefix from the CDN fallback path', () => {
|
|
89
|
+
const app = new Application({
|
|
90
|
+
router,
|
|
91
|
+
publicPath: '/v/',
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
expect(app.getCdnUrl()).toBe('/');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('should preserve APP_PUBLIC_PATH when removing the modern client prefix', () => {
|
|
98
|
+
const app = new Application({
|
|
99
|
+
router,
|
|
100
|
+
publicPath: '/nocobase/v/',
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
expect(app.getCdnUrl()).toBe('/nocobase/');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('should support a custom modern client prefix', () => {
|
|
107
|
+
window['__nocobase_modern_client_prefix__'] = 'modern';
|
|
108
|
+
const app = new Application({
|
|
109
|
+
router,
|
|
110
|
+
publicPath: '/nocobase/modern/',
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
expect(app.getCdnUrl()).toBe('/nocobase/');
|
|
114
|
+
});
|
|
115
|
+
|
|
61
116
|
it('should apply the provided favicon immediately', () => {
|
|
62
117
|
const app = new Application({ router });
|
|
63
118
|
|
|
@@ -132,7 +132,7 @@ fieldValidationConfigureRegistry.registerMany([
|
|
|
132
132
|
{ key: 'uri', label: 'URI', hasValue: false, params: [], paramsType: 'object' },
|
|
133
133
|
{
|
|
134
134
|
key: 'multiple',
|
|
135
|
-
label: 'Multiple',
|
|
135
|
+
label: 'Multiple of',
|
|
136
136
|
hasValue: true,
|
|
137
137
|
params: [{ key: 'base', label: 'Base', componentType: 'inputNumber', required: true }],
|
|
138
138
|
},
|
|
@@ -49,6 +49,21 @@ function CodeScannerContent({ visible, formatsToSupport, onClose, onScanSuccess
|
|
|
49
49
|
message.error(t('Code recognition failed, please scan again'));
|
|
50
50
|
}, [t]);
|
|
51
51
|
|
|
52
|
+
const showCameraStartFailure = useCallback(
|
|
53
|
+
(error: unknown) => {
|
|
54
|
+
const errorName = error instanceof Error ? error.name : '';
|
|
55
|
+
const errorMessage = error instanceof Error ? error.message : '';
|
|
56
|
+
const errorMap: Record<string, string> = {
|
|
57
|
+
NotFoundError: t('No camera device detected'),
|
|
58
|
+
NotAllowedError: t('You have not granted permission to use the camera'),
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
message.error(errorMap[errorName] || errorMessage || t('You have not granted permission to use the camera'));
|
|
62
|
+
onClose();
|
|
63
|
+
},
|
|
64
|
+
[onClose, t],
|
|
65
|
+
);
|
|
66
|
+
|
|
52
67
|
const handleScanSuccess = useCallback(
|
|
53
68
|
(text: string) => {
|
|
54
69
|
onScanSuccess(text);
|
|
@@ -64,6 +79,7 @@ function CodeScannerContent({ visible, formatsToSupport, onClose, onScanSuccess
|
|
|
64
79
|
scanBoxSize,
|
|
65
80
|
onScanSuccess: handleScanSuccess,
|
|
66
81
|
onScanFailure: showScanFailure,
|
|
82
|
+
onCameraStartFailure: showCameraStartFailure,
|
|
67
83
|
});
|
|
68
84
|
|
|
69
85
|
useEffect(() => {
|
|
@@ -129,7 +145,7 @@ function CodeScannerContent({ visible, formatsToSupport, onClose, onScanSuccess
|
|
|
129
145
|
inset: 0;
|
|
130
146
|
z-index: ${token.zIndexPopupBase + 1000};
|
|
131
147
|
overflow: hidden;
|
|
132
|
-
background:
|
|
148
|
+
background: #000;
|
|
133
149
|
`;
|
|
134
150
|
const scannerWrapperClass = css`
|
|
135
151
|
position: absolute;
|
|
@@ -137,16 +153,17 @@ function CodeScannerContent({ visible, formatsToSupport, onClose, onScanSuccess
|
|
|
137
153
|
overflow: hidden;
|
|
138
154
|
`;
|
|
139
155
|
const scannerClass = css`
|
|
156
|
+
position: relative;
|
|
140
157
|
width: 100%;
|
|
141
158
|
height: 100%;
|
|
142
|
-
|
|
143
|
-
justify-content: center;
|
|
144
|
-
align-items: center;
|
|
159
|
+
overflow: hidden;
|
|
145
160
|
|
|
146
161
|
video {
|
|
147
|
-
|
|
162
|
+
position: absolute !important;
|
|
163
|
+
inset: 0 !important;
|
|
164
|
+
width: 100% !important;
|
|
148
165
|
height: 100% !important;
|
|
149
|
-
|
|
166
|
+
object-fit: cover !important;
|
|
150
167
|
}
|
|
151
168
|
|
|
152
169
|
#qr-shaded-region {
|
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { render, waitFor } from '@testing-library/react';
|
|
10
|
+
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
11
11
|
import React, { useCallback } from 'react';
|
|
12
|
-
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
12
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
13
13
|
import { DEFAULT_CODE_FORMATS, getCodeScanBoxSize, useCodeScanner } from '../useCodeScanner';
|
|
14
14
|
|
|
15
15
|
type MockScannerInstance = {
|
|
@@ -44,6 +44,12 @@ const mocks = vi.hoisted(() => {
|
|
|
44
44
|
};
|
|
45
45
|
});
|
|
46
46
|
|
|
47
|
+
const jsQrMocks = vi.hoisted(() => {
|
|
48
|
+
return {
|
|
49
|
+
default: vi.fn(),
|
|
50
|
+
};
|
|
51
|
+
});
|
|
52
|
+
|
|
47
53
|
vi.mock('html5-qrcode', () => ({
|
|
48
54
|
Html5Qrcode: mocks.Html5Qrcode,
|
|
49
55
|
Html5QrcodeScannerState: {
|
|
@@ -66,22 +72,99 @@ vi.mock('html5-qrcode', () => ({
|
|
|
66
72
|
},
|
|
67
73
|
}));
|
|
68
74
|
|
|
69
|
-
|
|
75
|
+
vi.mock('jsqr', () => jsQrMocks);
|
|
76
|
+
|
|
77
|
+
function ScannerHost({
|
|
78
|
+
onCameraStartFailure,
|
|
79
|
+
onScanFailure,
|
|
80
|
+
scanBoxSize,
|
|
81
|
+
}: {
|
|
82
|
+
onCameraStartFailure?: (error: unknown) => void;
|
|
83
|
+
onScanFailure?: () => void;
|
|
84
|
+
scanBoxSize?: { width: number; height: number };
|
|
85
|
+
} = {}) {
|
|
70
86
|
const handleScanSuccess = useCallback(() => undefined, []);
|
|
71
87
|
|
|
72
88
|
useCodeScanner({
|
|
73
89
|
elementId: 'scanner',
|
|
74
90
|
enabled: true,
|
|
75
91
|
scanBoxSize,
|
|
92
|
+
onCameraStartFailure,
|
|
93
|
+
onScanFailure,
|
|
76
94
|
onScanSuccess: handleScanSuccess,
|
|
77
95
|
});
|
|
78
96
|
|
|
79
97
|
return <div id="scanner" />;
|
|
80
98
|
}
|
|
81
99
|
|
|
100
|
+
function FileScannerHost({ onScanSuccess }: { onScanSuccess: (text: string) => void }) {
|
|
101
|
+
const { startScanFile } = useCodeScanner({
|
|
102
|
+
elementId: 'scanner',
|
|
103
|
+
enabled: true,
|
|
104
|
+
onScanSuccess,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<>
|
|
109
|
+
<div id="scanner" />
|
|
110
|
+
<button onClick={() => startScanFile(new File(['qr'], 'qr.png', { type: 'image/png' }))}>scan file</button>
|
|
111
|
+
</>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function stubSafariBrowser() {
|
|
116
|
+
vi.spyOn(window.navigator, 'vendor', 'get').mockReturnValue('Apple Computer, Inc.');
|
|
117
|
+
vi.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue(
|
|
118
|
+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15',
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function stubImageElement(size: { width: number; height: number } = { width: 600, height: 400 }) {
|
|
123
|
+
vi.stubGlobal(
|
|
124
|
+
'Image',
|
|
125
|
+
class MockImage {
|
|
126
|
+
onabort: ((event: Event) => void) | null = null;
|
|
127
|
+
onerror: ((event: Event) => void) | null = null;
|
|
128
|
+
onload: (() => void) | null = null;
|
|
129
|
+
height = size.height;
|
|
130
|
+
naturalHeight = size.height;
|
|
131
|
+
naturalWidth = size.width;
|
|
132
|
+
width = size.width;
|
|
133
|
+
|
|
134
|
+
set src(_value: string) {
|
|
135
|
+
this.onload?.();
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
);
|
|
139
|
+
vi.stubGlobal('URL', {
|
|
140
|
+
...URL,
|
|
141
|
+
createObjectURL: vi.fn(() => 'blob:qr'),
|
|
142
|
+
revokeObjectURL: vi.fn(),
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function stubCanvas() {
|
|
147
|
+
const canvasContext = {
|
|
148
|
+
drawImage: vi.fn(),
|
|
149
|
+
getImageData: vi.fn((_x: number, _y: number, width: number, height: number) => ({
|
|
150
|
+
data: new Uint8ClampedArray(width * height * 4),
|
|
151
|
+
height,
|
|
152
|
+
width,
|
|
153
|
+
})),
|
|
154
|
+
} as unknown as CanvasRenderingContext2D;
|
|
155
|
+
|
|
156
|
+
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(canvasContext);
|
|
157
|
+
}
|
|
158
|
+
|
|
82
159
|
describe('useCodeScanner', () => {
|
|
83
160
|
beforeEach(() => {
|
|
84
161
|
vi.clearAllMocks();
|
|
162
|
+
jsQrMocks.default.mockReturnValue(undefined);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
afterEach(() => {
|
|
166
|
+
vi.unstubAllGlobals();
|
|
167
|
+
vi.restoreAllMocks();
|
|
85
168
|
});
|
|
86
169
|
|
|
87
170
|
it('starts scanning with QR code and barcode formats by default', async () => {
|
|
@@ -120,4 +203,98 @@ describe('useCodeScanner', () => {
|
|
|
120
203
|
|
|
121
204
|
expect(config?.qrbox?.(1200, 844)).toEqual({ width: 319, height: 240 });
|
|
122
205
|
});
|
|
206
|
+
|
|
207
|
+
it('clamps the scan box to the camera viewfinder size', async () => {
|
|
208
|
+
render(<ScannerHost scanBoxSize={{ width: 319, height: 240 }} />);
|
|
209
|
+
|
|
210
|
+
await waitFor(() => expect(mocks.start).toHaveBeenCalled());
|
|
211
|
+
|
|
212
|
+
const config = mocks.start.mock.calls[0]?.[1] as
|
|
213
|
+
| { qrbox?: (width: number, height: number) => { width: number; height: number } }
|
|
214
|
+
| undefined;
|
|
215
|
+
|
|
216
|
+
expect(config?.qrbox?.(200, 160)).toEqual({ width: 200, height: 160 });
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('reports camera start failures through the dedicated handler', async () => {
|
|
220
|
+
const cameraStartError = Object.assign(new Error('Camera failed'), { name: 'NotAllowedError' });
|
|
221
|
+
const handleCameraStartFailure = vi.fn();
|
|
222
|
+
const handleScanFailure = vi.fn();
|
|
223
|
+
mocks.start.mockRejectedValueOnce(cameraStartError);
|
|
224
|
+
|
|
225
|
+
render(<ScannerHost onCameraStartFailure={handleCameraStartFailure} onScanFailure={handleScanFailure} />);
|
|
226
|
+
|
|
227
|
+
await waitFor(() => expect(handleCameraStartFailure).toHaveBeenCalledWith(cameraStartError));
|
|
228
|
+
expect(handleScanFailure).not.toHaveBeenCalled();
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('uses jsQR first for Safari uploaded QR images', async () => {
|
|
232
|
+
const handleScanSuccess = vi.fn();
|
|
233
|
+
jsQrMocks.default.mockReturnValueOnce({ data: 'JSQR-CODE' });
|
|
234
|
+
stubSafariBrowser();
|
|
235
|
+
stubImageElement();
|
|
236
|
+
stubCanvas();
|
|
237
|
+
|
|
238
|
+
render(<FileScannerHost onScanSuccess={handleScanSuccess} />);
|
|
239
|
+
await waitFor(() => expect(mocks.start).toHaveBeenCalled());
|
|
240
|
+
|
|
241
|
+
fireEvent.click(screen.getByText('scan file'));
|
|
242
|
+
|
|
243
|
+
await waitFor(() => expect(handleScanSuccess).toHaveBeenCalledWith('JSQR-CODE'));
|
|
244
|
+
expect(jsQrMocks.default).toHaveBeenCalledWith(expect.any(Uint8ClampedArray), 600, 400, {
|
|
245
|
+
inversionAttempts: 'attemptBoth',
|
|
246
|
+
});
|
|
247
|
+
expect(mocks.scanFileV2).not.toHaveBeenCalled();
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('falls back to html5-qrcode for Safari uploaded files when jsQR does not decode a QR code', async () => {
|
|
251
|
+
const handleScanSuccess = vi.fn();
|
|
252
|
+
mocks.scanFileV2.mockResolvedValueOnce({ decodedText: 'BARCODE-CODE' });
|
|
253
|
+
stubSafariBrowser();
|
|
254
|
+
stubImageElement();
|
|
255
|
+
stubCanvas();
|
|
256
|
+
|
|
257
|
+
render(<FileScannerHost onScanSuccess={handleScanSuccess} />);
|
|
258
|
+
await waitFor(() => expect(mocks.start).toHaveBeenCalled());
|
|
259
|
+
|
|
260
|
+
fireEvent.click(screen.getByText('scan file'));
|
|
261
|
+
|
|
262
|
+
await waitFor(() => expect(handleScanSuccess).toHaveBeenCalledWith('BARCODE-CODE'));
|
|
263
|
+
expect(jsQrMocks.default).toHaveBeenCalled();
|
|
264
|
+
expect(mocks.scanFileV2).toHaveBeenCalled();
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('tries multiple image scales before falling back from Safari jsQR scanning', async () => {
|
|
268
|
+
const handleScanSuccess = vi.fn();
|
|
269
|
+
Array.from({ length: 8 }).forEach((_, index) => {
|
|
270
|
+
jsQrMocks.default.mockReturnValueOnce(index === 7 ? { data: 'SCALED-CODE' } : undefined);
|
|
271
|
+
});
|
|
272
|
+
stubSafariBrowser();
|
|
273
|
+
stubImageElement({ width: 4000, height: 3000 });
|
|
274
|
+
stubCanvas();
|
|
275
|
+
|
|
276
|
+
render(<FileScannerHost onScanSuccess={handleScanSuccess} />);
|
|
277
|
+
await waitFor(() => expect(mocks.start).toHaveBeenCalled());
|
|
278
|
+
|
|
279
|
+
fireEvent.click(screen.getByText('scan file'));
|
|
280
|
+
|
|
281
|
+
await waitFor(() => expect(handleScanSuccess).toHaveBeenCalledWith('SCALED-CODE'));
|
|
282
|
+
expect(jsQrMocks.default).toHaveBeenCalledTimes(8);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('tries enhanced grayscale QR images for blurry Safari uploads', async () => {
|
|
286
|
+
const handleScanSuccess = vi.fn();
|
|
287
|
+
jsQrMocks.default.mockReturnValueOnce(undefined).mockReturnValueOnce({ data: 'ENHANCED-CODE' });
|
|
288
|
+
stubSafariBrowser();
|
|
289
|
+
stubImageElement();
|
|
290
|
+
stubCanvas();
|
|
291
|
+
|
|
292
|
+
render(<FileScannerHost onScanSuccess={handleScanSuccess} />);
|
|
293
|
+
await waitFor(() => expect(mocks.start).toHaveBeenCalled());
|
|
294
|
+
|
|
295
|
+
fireEvent.click(screen.getByText('scan file'));
|
|
296
|
+
|
|
297
|
+
await waitFor(() => expect(handleScanSuccess).toHaveBeenCalledWith('ENHANCED-CODE'));
|
|
298
|
+
expect(jsQrMocks.default).toHaveBeenCalledTimes(2);
|
|
299
|
+
});
|
|
123
300
|
});
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { Html5Qrcode, Html5QrcodeScannerState, Html5QrcodeSupportedFormats } from 'html5-qrcode';
|
|
11
|
+
import jsQR from 'jsqr';
|
|
11
12
|
import { useCallback, useEffect, useState } from 'react';
|
|
12
13
|
import type { CodeFormatsToSupport } from './types';
|
|
13
14
|
|
|
@@ -24,8 +25,30 @@ type UseCodeScannerOptions = {
|
|
|
24
25
|
onScannerSizeChanged?: (size: ScannerSize) => void;
|
|
25
26
|
onScanSuccess: (text: string) => void;
|
|
26
27
|
onScanFailure?: () => void;
|
|
28
|
+
onCameraStartFailure?: (error: unknown) => void;
|
|
27
29
|
};
|
|
28
30
|
|
|
31
|
+
type ImageDataVariant = {
|
|
32
|
+
imageData: ImageData;
|
|
33
|
+
maxSize: number;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
type JsQRImageTransform = {
|
|
37
|
+
contrast?: number;
|
|
38
|
+
threshold?: number;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const QR_SCAN_IMAGE_SIZES = [3200, 2400, 1600, 1000];
|
|
42
|
+
const QR_SCAN_IMAGE_TRANSFORMS: JsQRImageTransform[] = [
|
|
43
|
+
{},
|
|
44
|
+
{ contrast: 3, threshold: 105 },
|
|
45
|
+
{ contrast: 2, threshold: 105 },
|
|
46
|
+
{ contrast: 3, threshold: 120 },
|
|
47
|
+
{ contrast: 2, threshold: 120 },
|
|
48
|
+
{ contrast: 3, threshold: 90 },
|
|
49
|
+
{ contrast: 4, threshold: 105 },
|
|
50
|
+
];
|
|
51
|
+
|
|
29
52
|
export const DEFAULT_CODE_FORMATS: CodeFormatsToSupport = [
|
|
30
53
|
Html5QrcodeSupportedFormats.QR_CODE,
|
|
31
54
|
Html5QrcodeSupportedFormats.CODE_128,
|
|
@@ -48,6 +71,13 @@ export function getCodeScanBoxSize(width: number, height: number) {
|
|
|
48
71
|
};
|
|
49
72
|
}
|
|
50
73
|
|
|
74
|
+
function clampCodeScanBoxSize(scanBoxSize: ScannerSize, width: number, height: number) {
|
|
75
|
+
return {
|
|
76
|
+
width: Math.min(scanBoxSize.width, width),
|
|
77
|
+
height: Math.min(scanBoxSize.height, height),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
51
81
|
async function stopScanner(scanner?: Html5Qrcode, options: { clear?: boolean } = {}) {
|
|
52
82
|
if (!scanner) {
|
|
53
83
|
return;
|
|
@@ -62,6 +92,126 @@ async function stopScanner(scanner?: Html5Qrcode, options: { clear?: boolean } =
|
|
|
62
92
|
}
|
|
63
93
|
}
|
|
64
94
|
|
|
95
|
+
function isSafariBrowser() {
|
|
96
|
+
const { userAgent, vendor } = navigator;
|
|
97
|
+
return /Apple/i.test(vendor) && /Safari/i.test(userAgent) && !/CriOS|FxiOS|EdgiOS|Chrome/i.test(userAgent);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function loadImage(file: File) {
|
|
101
|
+
return new Promise<HTMLImageElement>((resolve, reject) => {
|
|
102
|
+
const image = new Image();
|
|
103
|
+
const url = URL.createObjectURL(file);
|
|
104
|
+
const cleanup = () => URL.revokeObjectURL(url);
|
|
105
|
+
|
|
106
|
+
image.onload = () => {
|
|
107
|
+
cleanup();
|
|
108
|
+
resolve(image);
|
|
109
|
+
};
|
|
110
|
+
image.onerror = (event) => {
|
|
111
|
+
cleanup();
|
|
112
|
+
reject(event);
|
|
113
|
+
};
|
|
114
|
+
image.onabort = (event) => {
|
|
115
|
+
cleanup();
|
|
116
|
+
reject(event);
|
|
117
|
+
};
|
|
118
|
+
image.src = url;
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function getScanImageSizes(naturalWidth: number, naturalHeight: number) {
|
|
123
|
+
const maxSize = Math.max(naturalWidth, naturalHeight);
|
|
124
|
+
const cappedMaxSize = Math.min(maxSize, QR_SCAN_IMAGE_SIZES[0]);
|
|
125
|
+
return Array.from(new Set([cappedMaxSize, ...QR_SCAN_IMAGE_SIZES.filter((size) => size < cappedMaxSize)])).sort(
|
|
126
|
+
(a, b) => b - a,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function getImageDataFromImage(image: HTMLImageElement, maxSize: number): ImageDataVariant | undefined {
|
|
131
|
+
const naturalWidth = image.naturalWidth || image.width;
|
|
132
|
+
const naturalHeight = image.naturalHeight || image.height;
|
|
133
|
+
|
|
134
|
+
const scale = Math.min(1, maxSize / Math.max(naturalWidth, naturalHeight));
|
|
135
|
+
const width = Math.max(1, Math.round(naturalWidth * scale));
|
|
136
|
+
const height = Math.max(1, Math.round(naturalHeight * scale));
|
|
137
|
+
const canvas = document.createElement('canvas');
|
|
138
|
+
canvas.width = width;
|
|
139
|
+
canvas.height = height;
|
|
140
|
+
const context = canvas.getContext('2d');
|
|
141
|
+
if (!context) {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
context.drawImage(image, 0, 0, width, height);
|
|
146
|
+
return {
|
|
147
|
+
imageData: context.getImageData(0, 0, width, height),
|
|
148
|
+
maxSize,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function getImageDataVariants(file: File) {
|
|
153
|
+
const image = await loadImage(file);
|
|
154
|
+
const naturalWidth = image.naturalWidth || image.width;
|
|
155
|
+
const naturalHeight = image.naturalHeight || image.height;
|
|
156
|
+
|
|
157
|
+
if (!naturalWidth || !naturalHeight) {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return getScanImageSizes(naturalWidth, naturalHeight)
|
|
162
|
+
.map((maxSize) => getImageDataFromImage(image, maxSize))
|
|
163
|
+
.filter((variant): variant is ImageDataVariant => !!variant);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function getTransformedImageData(imageData: ImageData, transform: JsQRImageTransform) {
|
|
167
|
+
const { contrast = 1, threshold } = transform;
|
|
168
|
+
if (contrast === 1 && threshold == null) {
|
|
169
|
+
return imageData.data;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const data = new Uint8ClampedArray(imageData.data);
|
|
173
|
+
for (let index = 0; index < data.length; index += 4) {
|
|
174
|
+
let luminance = data[index] * 0.299 + data[index + 1] * 0.587 + data[index + 2] * 0.114;
|
|
175
|
+
luminance = Math.max(0, Math.min(255, (luminance - 128) * contrast + 128));
|
|
176
|
+
if (threshold != null) {
|
|
177
|
+
luminance = luminance < threshold ? 0 : 255;
|
|
178
|
+
}
|
|
179
|
+
data[index] = luminance;
|
|
180
|
+
data[index + 1] = luminance;
|
|
181
|
+
data[index + 2] = luminance;
|
|
182
|
+
}
|
|
183
|
+
return data;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function scanFileWithJsQR(file: File, formatsToSupport?: CodeFormatsToSupport) {
|
|
187
|
+
const formats = formatsToSupport?.length ? formatsToSupport : DEFAULT_CODE_FORMATS;
|
|
188
|
+
if (!formats.includes(Html5QrcodeSupportedFormats.QR_CODE)) {
|
|
189
|
+
throw new Error('QR_CODE is not included in the requested formats');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const variants = await getImageDataVariants(file);
|
|
193
|
+
if (!variants?.length) {
|
|
194
|
+
throw new Error('Failed to prepare uploaded image for QR decoding');
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
for (const { imageData } of variants) {
|
|
198
|
+
for (const transform of QR_SCAN_IMAGE_TRANSFORMS) {
|
|
199
|
+
const data = getTransformedImageData(imageData, transform);
|
|
200
|
+
const qrCode = jsQR(data, imageData.width, imageData.height, { inversionAttempts: 'attemptBoth' });
|
|
201
|
+
if (qrCode?.data) {
|
|
202
|
+
return qrCode.data;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
throw new Error('No QR code decoded by jsQR');
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function shouldScanQrWithJsQR(formatsToSupport?: CodeFormatsToSupport) {
|
|
211
|
+
const formats = formatsToSupport?.length ? formatsToSupport : DEFAULT_CODE_FORMATS;
|
|
212
|
+
return formats.includes(Html5QrcodeSupportedFormats.QR_CODE);
|
|
213
|
+
}
|
|
214
|
+
|
|
65
215
|
export function useCodeScanner({
|
|
66
216
|
enabled,
|
|
67
217
|
elementId,
|
|
@@ -70,6 +220,7 @@ export function useCodeScanner({
|
|
|
70
220
|
onScannerSizeChanged,
|
|
71
221
|
onScanSuccess,
|
|
72
222
|
onScanFailure,
|
|
223
|
+
onCameraStartFailure,
|
|
73
224
|
}: UseCodeScannerOptions) {
|
|
74
225
|
const [scanner, setScanner] = useState<Html5Qrcode>();
|
|
75
226
|
|
|
@@ -81,7 +232,7 @@ export function useCodeScanner({
|
|
|
81
232
|
fps: 10,
|
|
82
233
|
qrbox(width, height) {
|
|
83
234
|
onScannerSizeChanged?.({ width, height });
|
|
84
|
-
return scanBoxSize ?? getCodeScanBoxSize(width, height);
|
|
235
|
+
return clampCodeScanBoxSize(scanBoxSize ?? getCodeScanBoxSize(width, height), width, height);
|
|
85
236
|
},
|
|
86
237
|
},
|
|
87
238
|
(decodedText) => {
|
|
@@ -95,6 +246,16 @@ export function useCodeScanner({
|
|
|
95
246
|
|
|
96
247
|
const startScanFile = useCallback(
|
|
97
248
|
async (file: File) => {
|
|
249
|
+
if (isSafariBrowser() && shouldScanQrWithJsQR(formatsToSupport)) {
|
|
250
|
+
try {
|
|
251
|
+
const decodedText = await scanFileWithJsQR(file, formatsToSupport);
|
|
252
|
+
onScanSuccess(decodedText);
|
|
253
|
+
return;
|
|
254
|
+
} catch {
|
|
255
|
+
// Fall through to html5-qrcode so barcode uploads still work in Safari.
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
98
259
|
if (!scanner) {
|
|
99
260
|
return;
|
|
100
261
|
}
|
|
@@ -103,12 +264,12 @@ export function useCodeScanner({
|
|
|
103
264
|
try {
|
|
104
265
|
const result = await scanner.scanFileV2(file, false);
|
|
105
266
|
onScanSuccess(result.decodedText);
|
|
106
|
-
} catch
|
|
267
|
+
} catch {
|
|
107
268
|
onScanFailure?.();
|
|
108
269
|
await startScanCamera(scanner);
|
|
109
270
|
}
|
|
110
271
|
},
|
|
111
|
-
[onScanFailure, onScanSuccess, scanner, startScanCamera],
|
|
272
|
+
[formatsToSupport, onScanFailure, onScanSuccess, scanner, startScanCamera],
|
|
112
273
|
);
|
|
113
274
|
|
|
114
275
|
useEffect(() => {
|
|
@@ -121,14 +282,18 @@ export function useCodeScanner({
|
|
|
121
282
|
verbose: false,
|
|
122
283
|
});
|
|
123
284
|
setScanner(scannerInstance);
|
|
124
|
-
startScanCamera(scannerInstance).catch(() => {
|
|
285
|
+
startScanCamera(scannerInstance).catch((error: unknown) => {
|
|
286
|
+
if (onCameraStartFailure) {
|
|
287
|
+
onCameraStartFailure(error);
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
125
290
|
onScanFailure?.();
|
|
126
291
|
});
|
|
127
292
|
|
|
128
293
|
return () => {
|
|
129
294
|
stopScanner(scannerInstance, { clear: true }).catch(() => undefined);
|
|
130
295
|
};
|
|
131
|
-
}, [elementId, enabled, formatsToSupport, onScanFailure, startScanCamera]);
|
|
296
|
+
}, [elementId, enabled, formatsToSupport, onCameraStartFailure, onScanFailure, startScanCamera]);
|
|
132
297
|
|
|
133
298
|
return {
|
|
134
299
|
startScanFile,
|