@nocobase/client-v2 2.2.0-beta.8 → 2.3.0-alpha.1

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.
Files changed (164) hide show
  1. package/es/APIClient.d.ts +1 -3
  2. package/es/BaseApplication.d.ts +3 -0
  3. package/es/RouteRepository.d.ts +8 -0
  4. package/es/RouterManager.d.ts +15 -0
  5. package/es/authRedirect.d.ts +1 -0
  6. package/es/collection-field-interface/CollectionFieldInterface.d.ts +1 -0
  7. package/es/collection-field-interface/CollectionFieldInterfaceManager.d.ts +1 -0
  8. package/es/components/category-tabs/SortableCategoryTabs.d.ts +55 -0
  9. package/es/components/category-tabs/index.d.ts +9 -0
  10. package/es/components/form/ScanInput/useCodeScanner.d.ts +2 -1
  11. package/es/components/form/TypedVariableInput.d.ts +31 -5
  12. package/es/components/form/filter/CollectionFilter.d.ts +2 -0
  13. package/es/components/form/filter/CollectionFilterItem.d.ts +11 -1
  14. package/es/components/form/filter/CollectionFilterPanel.d.ts +2 -0
  15. package/es/components/form/filter/index.d.ts +2 -0
  16. package/es/components/form/filter/useFilterActionProps.d.ts +2 -0
  17. package/es/components/index.d.ts +1 -0
  18. package/es/entry-actions/EntryActionManager.d.ts +24 -0
  19. package/es/entry-actions/index.d.ts +9 -0
  20. package/es/flow/actions/index.d.ts +1 -1
  21. package/es/flow/actions/linkageRules.d.ts +2 -0
  22. package/es/flow/admin-shell/BaseLayoutModel.d.ts +6 -0
  23. package/es/flow/admin-shell/BaseLayoutRouteCoordinator.d.ts +7 -0
  24. package/es/flow/admin-shell/admin-layout/AdminLayoutMenuModels.d.ts +1 -0
  25. package/es/flow/admin-shell/admin-layout/AppListRender.d.ts +2 -1
  26. package/es/flow/admin-shell/admin-layout/AppSwitcherActionPanelModel.d.ts +24 -0
  27. package/es/flow/admin-shell/admin-layout/index.d.ts +1 -0
  28. package/es/flow/admin-shell/admin-layout/useApplications.d.ts +7 -2
  29. package/es/flow/components/FieldAssignExactDatePicker.d.ts +1 -0
  30. package/es/flow/components/FieldAssignValueInput.d.ts +9 -0
  31. package/es/flow/components/RunJSValueEditor.d.ts +1 -0
  32. package/es/flow/components/filter/FilterGroup.d.ts +1 -0
  33. package/es/flow/components/filter/VariableFilterItem.d.ts +6 -1
  34. package/es/flow/index.d.ts +1 -0
  35. package/es/flow/models/base/ActionModelCore.d.ts +2 -0
  36. package/es/flow/models/base/PageModel/PageModel.d.ts +12 -0
  37. package/es/flow/models/base/PageModel/PageModelTabBar.d.ts +22 -0
  38. package/es/flow/models/base/PageModel/PageTabModel.d.ts +12 -0
  39. package/es/flow/models/blocks/form/QuickEditFormModel.d.ts +17 -2
  40. package/es/flow/routeTransientInputArgs.d.ts +14 -0
  41. package/es/flow-compat/fieldValidationConstants.d.ts +1 -1
  42. package/es/flow-compat/routeTypes.d.ts +1 -0
  43. package/es/index.d.ts +6 -0
  44. package/es/index.mjs +279 -158
  45. package/es/utils/getRouteRuntimeVersion.d.ts +23 -0
  46. package/es/utils/index.d.ts +1 -0
  47. package/es/utils/markdownSanitize.d.ts +11 -0
  48. package/lib/index.js +280 -159
  49. package/lib/locale/languageCodes.js +2 -1
  50. package/package.json +8 -7
  51. package/src/APIClient.ts +1 -10
  52. package/src/Application.tsx +4 -0
  53. package/src/BaseApplication.tsx +13 -6
  54. package/src/RouteRepository.ts +25 -0
  55. package/src/RouterManager.tsx +142 -1
  56. package/src/__tests__/RouteRepository.test.ts +23 -0
  57. package/src/__tests__/RouterManager.test.ts +125 -0
  58. package/src/__tests__/app.test.tsx +73 -0
  59. package/src/__tests__/authRedirect.test.ts +17 -0
  60. package/src/__tests__/browserChecker.test.ts +61 -0
  61. package/src/__tests__/getRouteRuntimeVersion.test.ts +68 -0
  62. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +28 -0
  63. package/src/authRedirect.ts +38 -0
  64. package/src/collection-field-interface/CollectionFieldInterface.ts +1 -0
  65. package/src/collection-field-interface/CollectionFieldInterfaceManager.ts +1 -0
  66. package/src/collection-manager/field-validation.ts +1 -1
  67. package/src/components/README.md +7 -1
  68. package/src/components/README.zh-CN.md +6 -1
  69. package/src/components/category-tabs/SortableCategoryTabs.tsx +210 -0
  70. package/src/components/category-tabs/index.ts +10 -0
  71. package/src/components/form/JsonTextArea.tsx +4 -0
  72. package/src/components/form/ScanInput/CodeScanner.tsx +23 -6
  73. package/src/components/form/ScanInput/__tests__/useCodeScanner.test.tsx +180 -3
  74. package/src/components/form/ScanInput/useCodeScanner.ts +170 -5
  75. package/src/components/form/TypedVariableInput.tsx +452 -101
  76. package/src/components/form/__tests__/TypedVariableInput.test.tsx +364 -12
  77. package/src/components/form/filter/CollectionFilter.tsx +4 -0
  78. package/src/components/form/filter/CollectionFilterItem.tsx +32 -7
  79. package/src/components/form/filter/CollectionFilterPanel.tsx +4 -0
  80. package/src/components/form/filter/__tests__/CollectionFilterItem.test.tsx +38 -0
  81. package/src/components/form/filter/__tests__/useFilterActionProps.test.tsx +95 -0
  82. package/src/components/form/filter/index.ts +2 -0
  83. package/src/components/form/filter/useFilterActionProps.ts +37 -6
  84. package/src/components/index.ts +1 -0
  85. package/src/entry-actions/EntryActionManager.ts +76 -0
  86. package/src/entry-actions/index.ts +10 -0
  87. package/src/flow/FlowPage.tsx +38 -3
  88. package/src/flow/__tests__/FlowPage.test.tsx +201 -27
  89. package/src/flow/__tests__/FlowRoute.test.tsx +549 -4
  90. package/src/flow/actions/__tests__/dataScopeFilter.test.ts +62 -0
  91. package/src/flow/actions/__tests__/linkageRules.tab.test.ts +171 -0
  92. package/src/flow/actions/__tests__/linkageRulesRefresh.test.ts +7 -3
  93. package/src/flow/actions/__tests__/openView.defineProps.route.test.tsx +80 -20
  94. package/src/flow/actions/dataScopeFilter.ts +10 -1
  95. package/src/flow/actions/dateTimeFormat.tsx +2 -2
  96. package/src/flow/actions/index.ts +2 -0
  97. package/src/flow/actions/linkageRules.tsx +86 -11
  98. package/src/flow/actions/linkageRulesRefresh.tsx +2 -6
  99. package/src/flow/actions/openView.tsx +21 -1
  100. package/src/flow/admin-shell/BaseLayoutModel.tsx +124 -0
  101. package/src/flow/admin-shell/BaseLayoutRouteCoordinator.ts +184 -42
  102. package/src/flow/admin-shell/__tests__/AdminLayoutRouteCoordinator.test.ts +1016 -119
  103. package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +41 -5
  104. package/src/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.test.tsx +177 -1
  105. package/src/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.tsx +20 -0
  106. package/src/flow/admin-shell/admin-layout/AppListRender.tsx +13 -2
  107. package/src/flow/admin-shell/admin-layout/AppSwitcherActionPanelModel.tsx +289 -0
  108. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutComponent.test.tsx +253 -6
  109. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutModel.test.tsx +436 -2
  110. package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx +48 -0
  111. package/src/flow/admin-shell/admin-layout/index.ts +1 -0
  112. package/src/flow/admin-shell/admin-layout/useApplications.tsx +81 -12
  113. package/src/flow/common/Markdown/Display.tsx +14 -3
  114. package/src/flow/common/Markdown/Edit.tsx +19 -7
  115. package/src/flow/components/FieldAssignExactDatePicker.tsx +25 -11
  116. package/src/flow/components/FieldAssignValueInput.tsx +91 -20
  117. package/src/flow/components/FlowRoute.tsx +134 -18
  118. package/src/flow/components/RunJSValueEditor.tsx +9 -1
  119. package/src/flow/components/__tests__/FieldAssignValueInput.context.test.tsx +216 -0
  120. package/src/flow/components/filter/FilterGroup.tsx +33 -5
  121. package/src/flow/components/filter/VariableFilterItem.tsx +158 -10
  122. package/src/flow/components/filter/__tests__/FilterGroup.test.tsx +45 -0
  123. package/src/flow/components/filter/__tests__/VariableFilterItem.leftMetaTree.test.tsx +9 -0
  124. package/src/flow/components/filter/__tests__/VariableFilterItem.test.tsx +70 -1
  125. package/src/flow/index.ts +1 -0
  126. package/src/flow/internal/components/Markdown/util.ts +2 -1
  127. package/src/flow/models/base/ActionModel.tsx +10 -8
  128. package/src/flow/models/base/ActionModelCore.tsx +5 -0
  129. package/src/flow/models/base/PageModel/PageModel.tsx +387 -53
  130. package/src/flow/models/base/PageModel/PageModelTabBar.tsx +114 -0
  131. package/src/flow/models/base/PageModel/PageTabModel.tsx +184 -3
  132. package/src/flow/models/base/PageModel/__tests__/PageModel.test.ts +790 -10
  133. package/src/flow/models/base/PageModel/__tests__/PageModelTabBar.module-isolation.test.tsx +130 -0
  134. package/src/flow/models/base/PageModel/__tests__/PageModelTabBar.test.tsx +118 -0
  135. package/src/flow/models/base/PageModel/__tests__/PageTabModel.test.ts +572 -1
  136. package/src/flow/models/blocks/filter-form/FilterFormBlockModel.tsx +1 -0
  137. package/src/flow/models/blocks/filter-form/FilterFormItemModel.tsx +27 -12
  138. package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.defineChildren.test.ts +36 -0
  139. package/src/flow/models/blocks/filter-form/__tests__/defaultValues.wiring.test.ts +34 -0
  140. package/src/flow/models/blocks/form/QuickEditFormModel.tsx +189 -36
  141. package/src/flow/models/blocks/form/__tests__/QuickEditFormModel.quickEdit.test.ts +350 -2
  142. package/src/flow/models/blocks/table/TableActionsColumnModel.tsx +2 -1
  143. package/src/flow/models/blocks/table/TableBlockModel.tsx +1 -1
  144. package/src/flow/models/blocks/table/TableColumnModel.tsx +6 -2
  145. package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +51 -0
  146. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +2 -1
  147. package/src/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.tsx +45 -13
  148. package/src/flow/models/fields/TextareaFieldModel.tsx +42 -19
  149. package/src/flow/models/fields/VariableFieldFormModel.tsx +3 -3
  150. package/src/flow/models/fields/__tests__/TextareaFieldModel.test.tsx +32 -1
  151. package/src/flow/models/fields/__tests__/VariableFieldFormModel.test.tsx +51 -0
  152. package/src/flow/models/fields/mobile-components/MobileLazySelect.tsx +20 -10
  153. package/src/flow/models/fields/mobile-components/MobileSelect.tsx +24 -12
  154. package/src/flow/models/topbar/TopbarActionModel.tsx +78 -3
  155. package/src/flow/routeTransientInputArgs.ts +27 -0
  156. package/src/flow/utils/__tests__/dateTimeFormat.test.ts +42 -0
  157. package/src/flow-compat/fieldValidationConstants.ts +1 -1
  158. package/src/flow-compat/routeTypes.ts +1 -0
  159. package/src/index.ts +6 -0
  160. package/src/locale/languageCodes.ts +2 -1
  161. package/src/nocobase-buildin-plugin/index.tsx +19 -1
  162. package/src/utils/getRouteRuntimeVersion.ts +185 -0
  163. package/src/utils/index.tsx +1 -0
  164. package/src/utils/markdownSanitize.ts +88 -0
@@ -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
- function ScannerHost({ scanBoxSize }: { scanBoxSize?: { width: number; height: number } } = {}) {
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 (error) {
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,