@nocobase/client-v2 2.3.0-alpha.1 → 3.0.0-alpha.2
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/acl/ACLProvider.d.ts +10 -0
- package/es/authRedirect.d.ts +5 -0
- package/es/components/AttachmentUpload.d.ts +51 -0
- package/es/components/form/ScanInput/useCodeScanner.d.ts +2 -2
- package/es/components/index.d.ts +1 -0
- package/es/flow/actions/linkageRules.d.ts +1 -0
- package/es/flow/admin-shell/admin-layout/NocoBaseLogo.d.ts +10 -0
- package/es/flow/components/FlowRoute.d.ts +2 -2
- package/es/flow/internal/registerDeviceTypeContext.d.ts +10 -0
- package/es/flow/models/base/ActionModelCore.d.ts +4 -2
- package/es/flow/models/base/BlockGridModel.d.ts +3 -0
- package/es/flow/models/blocks/table/TableColumnModel.d.ts +1 -0
- package/es/flow/models/fields/DateTimeFieldModel/dateLimit.d.ts +15 -7
- package/es/index.d.ts +4 -1
- package/es/index.mjs +139 -125
- package/es/nocobase-buildin-plugin/currentUserAuthStatus.d.ts +11 -0
- package/es/nocobase-buildin-plugin/index.d.ts +9 -0
- package/es/settings-app/SettingsApplication.d.ts +18 -0
- package/es/settings-app/SettingsBrand.d.ts +16 -0
- package/es/settings-app/SettingsBuildInPlugin.d.ts +9 -0
- package/es/settings-app/SettingsDocumentRedirect.d.ts +10 -0
- package/es/settings-app/SettingsGroupNav.d.ts +17 -0
- package/es/settings-app/SettingsPluginSettingsManager.d.ts +14 -0
- package/es/settings-app/SettingsRouterManager.d.ts +15 -0
- package/es/settings-app/SettingsSearch.d.ts +17 -0
- package/es/settings-app/SettingsShell.d.ts +10 -0
- package/es/settings-app/isSettingsApp.d.ts +18 -0
- package/es/settings-app/runtimePaths.d.ts +18 -0
- package/es/settings-app/settingsDocumentPath.d.ts +14 -0
- package/es/settings-app/settingsTheme.d.ts +87 -0
- package/es/settings-app/useSettingsThemeConfig.d.ts +22 -0
- package/es/settings-center/AdminSettingsLayout.d.ts +2 -2
- package/es/settings-center/groups.d.ts +66 -0
- package/es/settings-center/useSettingsGroups.d.ts +36 -0
- package/es/settings-center/useSettingsSearch.d.ts +35 -0
- package/es/settings-center/utils.d.ts +24 -0
- package/es/theme/index.d.ts +1 -0
- package/es/theme/itemActive.d.ts +24 -0
- package/es/theme/type.d.ts +9 -0
- package/lib/index.js +141 -127
- package/lib/locale/languageCodes.js +1 -0
- package/package.json +8 -7
- package/src/__tests__/authRedirect.test.ts +137 -0
- package/src/__tests__/browserChecker.test.ts +124 -6
- package/src/__tests__/mockSettingsApplication.ts +29 -0
- package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +51 -10
- package/src/__tests__/plugin-manager.test.tsx +7 -6
- package/src/__tests__/settings-application.test.ts +164 -0
- package/src/__tests__/settings-auth-routes.test.ts +71 -0
- package/src/__tests__/settings-center.test.tsx +38 -37
- package/src/__tests__/settings-layout-root.test.tsx +260 -0
- package/src/__tests__/settings-runtime-paths.test.ts +99 -0
- package/src/__tests__/settings-shell.test.tsx +200 -0
- package/src/acl/ACLProvider.tsx +21 -0
- package/src/authRedirect.ts +72 -3
- package/src/components/AttachmentUpload.tsx +275 -0
- package/src/components/form/ScanInput/CodeScanner.tsx +61 -34
- package/src/components/form/ScanInput/__tests__/CodeScanner.test.tsx +101 -0
- package/src/components/form/ScanInput/__tests__/useCodeScanner.test.tsx +114 -18
- package/src/components/form/ScanInput/useCodeScanner.ts +166 -23
- package/src/components/index.ts +1 -0
- package/src/flow/__tests__/FlowRoute.test.tsx +126 -8
- package/src/flow/__tests__/PluginFlowEngine.test.ts +58 -0
- package/src/flow/actions/__tests__/actionLinkageRules.forkProps.test.ts +314 -0
- package/src/flow/actions/__tests__/dataScopeFormValueClear.test.ts +102 -0
- package/src/flow/actions/linkageRules.tsx +26 -6
- package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +1 -56
- package/src/flow/admin-shell/admin-layout/NocoBaseLogo.tsx +77 -0
- package/src/flow/admin-shell/admin-layout/TopbarActionsBar.tsx +37 -14
- package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx +77 -13
- package/src/flow/common/Markdown/style.ts +4 -0
- package/src/flow/components/BlockItemCard.tsx +2 -2
- package/src/flow/components/FlowRoute.tsx +71 -29
- package/src/flow/index.ts +2 -0
- package/src/flow/internal/registerDeviceTypeContext.ts +39 -0
- package/src/flow/models/base/ActionModelCore.tsx +11 -2
- package/src/flow/models/base/BlockGridModel.tsx +26 -0
- package/src/flow/models/base/CollectionBlockModel.tsx +6 -2
- package/src/flow/models/base/PageModel/PageTabModel.tsx +67 -17
- package/src/flow/models/base/PageModel/__tests__/PageTabModel.test.ts +509 -12
- package/src/flow/models/base/__tests__/ActionModelCore.render.test.tsx +49 -0
- package/src/flow/models/base/__tests__/BlockGridModel.selectSceneActivation.test.ts +124 -0
- package/src/flow/models/base/__tests__/CollectionBlockModel.addAppends.test.ts +41 -0
- package/src/flow/models/blocks/filter-form/FilterFormBlockModel.tsx +27 -3
- package/src/flow/models/blocks/filter-form/__tests__/defaultValues.wiring.test.ts +32 -4
- package/src/flow/models/blocks/js-block/JSBlock.tsx +1 -1
- package/src/flow/models/blocks/table/TableBlockModel.tsx +3 -1
- package/src/flow/models/blocks/table/TableColumnModel.tsx +30 -1
- package/src/flow/models/blocks/table/__tests__/TableBlockModel.quickEditRefresh.test.ts +12 -0
- package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +33 -0
- package/src/flow/models/fields/DateTimeFieldModel/__tests__/DateTimeNoTzFieldModel.dateLimit.test.tsx +149 -0
- package/src/flow/models/fields/DateTimeFieldModel/dateLimit.ts +149 -113
- package/src/flow/models/fields/JsonFieldModel.tsx +31 -8
- package/src/flow/models/fields/__tests__/JsonFieldModel.test.ts +82 -0
- package/src/flow/models/topbar/TopbarActionModel.tsx +52 -180
- package/src/flow/utils/dataScopeFormValueClear.ts +4 -3
- package/src/index.ts +10 -1
- package/src/locale/languageCodes.ts +1 -0
- package/src/nocobase-buildin-plugin/currentUserAuthStatus.ts +58 -0
- package/src/nocobase-buildin-plugin/index.tsx +99 -71
- package/src/settings-app/SettingsApplication.ts +31 -0
- package/src/settings-app/SettingsBrand.tsx +108 -0
- package/src/settings-app/SettingsBuildInPlugin.ts +10 -0
- package/src/settings-app/SettingsDocumentRedirect.tsx +26 -0
- package/src/settings-app/SettingsGroupNav.tsx +124 -0
- package/src/settings-app/SettingsPluginSettingsManager.ts +38 -0
- package/src/settings-app/SettingsRouterManager.ts +92 -0
- package/src/settings-app/SettingsSearch.tsx +238 -0
- package/src/settings-app/SettingsShell.tsx +167 -0
- package/src/settings-app/isSettingsApp.ts +21 -0
- package/src/settings-app/runtimePaths.ts +104 -0
- package/src/settings-app/settingsDocumentPath.ts +66 -0
- package/src/settings-app/settingsTheme.ts +301 -0
- package/src/settings-app/useSettingsThemeConfig.ts +100 -0
- package/src/settings-center/AdminSettingsLayout.tsx +122 -141
- package/src/settings-center/SystemSettingsPage.tsx +4 -170
- package/src/settings-center/groups.ts +108 -0
- package/src/settings-center/useSettingsGroups.ts +105 -0
- package/src/settings-center/useSettingsSearch.ts +173 -0
- package/src/settings-center/utils.tsx +50 -1
- package/src/theme/index.tsx +1 -0
- package/src/theme/itemActive.ts +31 -0
- package/src/theme/type.ts +10 -0
|
@@ -7,13 +7,15 @@
|
|
|
7
7
|
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
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 { DEFAULT_CODE_FORMATS, getCodeScanBoxSize, useCodeScanner } from '../useCodeScanner';
|
|
13
|
+
import { DEFAULT_CODE_FORMATS, getCodeScanBoxSize, scanQrVideoFrame, useCodeScanner } from '../useCodeScanner';
|
|
14
14
|
|
|
15
15
|
type MockScannerInstance = {
|
|
16
|
+
applyVideoConstraints: ReturnType<typeof vi.fn>;
|
|
16
17
|
clear: ReturnType<typeof vi.fn>;
|
|
18
|
+
getRunningTrackCapabilities: ReturnType<typeof vi.fn>;
|
|
17
19
|
getState: ReturnType<typeof vi.fn>;
|
|
18
20
|
scanFileV2: ReturnType<typeof vi.fn>;
|
|
19
21
|
start: ReturnType<typeof vi.fn>;
|
|
@@ -24,18 +26,24 @@ const mocks = vi.hoisted(() => {
|
|
|
24
26
|
const start = vi.fn().mockResolvedValue(null);
|
|
25
27
|
const stop = vi.fn().mockResolvedValue(null);
|
|
26
28
|
const clear = vi.fn();
|
|
29
|
+
const applyVideoConstraints = vi.fn().mockResolvedValue(undefined);
|
|
30
|
+
const getRunningTrackCapabilities = vi.fn(() => ({}));
|
|
27
31
|
const getState = vi.fn(() => 1);
|
|
28
32
|
const scanFileV2 = vi.fn();
|
|
29
33
|
const Html5Qrcode = vi.fn(function MockHtml5Qrcode(this: MockScannerInstance) {
|
|
30
34
|
this.start = start;
|
|
31
35
|
this.stop = stop;
|
|
32
36
|
this.clear = clear;
|
|
37
|
+
this.applyVideoConstraints = applyVideoConstraints;
|
|
38
|
+
this.getRunningTrackCapabilities = getRunningTrackCapabilities;
|
|
33
39
|
this.getState = getState;
|
|
34
40
|
this.scanFileV2 = scanFileV2;
|
|
35
41
|
});
|
|
36
42
|
|
|
37
43
|
return {
|
|
44
|
+
applyVideoConstraints,
|
|
38
45
|
clear,
|
|
46
|
+
getRunningTrackCapabilities,
|
|
39
47
|
getState,
|
|
40
48
|
Html5Qrcode,
|
|
41
49
|
scanFileV2,
|
|
@@ -77,18 +85,15 @@ vi.mock('jsqr', () => jsQrMocks);
|
|
|
77
85
|
function ScannerHost({
|
|
78
86
|
onCameraStartFailure,
|
|
79
87
|
onScanFailure,
|
|
80
|
-
scanBoxSize,
|
|
81
88
|
}: {
|
|
82
89
|
onCameraStartFailure?: (error: unknown) => void;
|
|
83
90
|
onScanFailure?: () => void;
|
|
84
|
-
scanBoxSize?: { width: number; height: number };
|
|
85
91
|
} = {}) {
|
|
86
92
|
const handleScanSuccess = useCallback(() => undefined, []);
|
|
87
93
|
|
|
88
94
|
useCodeScanner({
|
|
89
95
|
elementId: 'scanner',
|
|
90
96
|
enabled: true,
|
|
91
|
-
scanBoxSize,
|
|
92
97
|
onCameraStartFailure,
|
|
93
98
|
onScanFailure,
|
|
94
99
|
onScanSuccess: handleScanSuccess,
|
|
@@ -159,6 +164,11 @@ function stubCanvas() {
|
|
|
159
164
|
describe('useCodeScanner', () => {
|
|
160
165
|
beforeEach(() => {
|
|
161
166
|
vi.clearAllMocks();
|
|
167
|
+
mocks.applyVideoConstraints.mockResolvedValue(undefined);
|
|
168
|
+
mocks.getRunningTrackCapabilities.mockReturnValue({});
|
|
169
|
+
mocks.start.mockResolvedValue(null);
|
|
170
|
+
mocks.stop.mockResolvedValue(null);
|
|
171
|
+
mocks.getState.mockReturnValue(1);
|
|
162
172
|
jsQrMocks.default.mockReturnValue(undefined);
|
|
163
173
|
});
|
|
164
174
|
|
|
@@ -188,32 +198,73 @@ describe('useCodeScanner', () => {
|
|
|
188
198
|
| { qrbox?: (width: number, height: number) => { width: number; height: number } }
|
|
189
199
|
| undefined;
|
|
190
200
|
|
|
191
|
-
expect(config?.qrbox?.(
|
|
192
|
-
expect(getCodeScanBoxSize(
|
|
201
|
+
expect(config?.qrbox?.(1280, 720)).toEqual({ width: 1152, height: 504 });
|
|
202
|
+
expect(getCodeScanBoxSize(1280, 720)).toEqual({ width: 1152, height: 504 });
|
|
193
203
|
});
|
|
194
204
|
|
|
195
|
-
it('
|
|
196
|
-
render(<ScannerHost
|
|
205
|
+
it('uses optimized camera constraints without requiring a user-selected scan mode', async () => {
|
|
206
|
+
render(<ScannerHost />);
|
|
197
207
|
|
|
198
208
|
await waitFor(() => expect(mocks.start).toHaveBeenCalled());
|
|
199
209
|
|
|
200
210
|
const config = mocks.start.mock.calls[0]?.[1] as
|
|
201
|
-
| {
|
|
211
|
+
| {
|
|
212
|
+
disableFlip?: boolean;
|
|
213
|
+
fps?: number;
|
|
214
|
+
qrbox?: (width: number, height: number) => { width: number; height: number };
|
|
215
|
+
videoConstraints?: MediaTrackConstraints;
|
|
216
|
+
}
|
|
202
217
|
| undefined;
|
|
203
218
|
|
|
204
|
-
expect(config?.qrbox?.(
|
|
219
|
+
expect(config?.qrbox?.(1280, 720)).toEqual({ width: 1152, height: 504 });
|
|
220
|
+
expect(config).toMatchObject({
|
|
221
|
+
disableFlip: false,
|
|
222
|
+
fps: 8,
|
|
223
|
+
videoConstraints: {
|
|
224
|
+
facingMode: { ideal: 'environment' },
|
|
225
|
+
width: { ideal: 1920 },
|
|
226
|
+
height: { ideal: 1080 },
|
|
227
|
+
frameRate: { ideal: 30 },
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
expect(mocks.Html5Qrcode).toHaveBeenCalledWith('scanner', {
|
|
231
|
+
formatsToSupport: DEFAULT_CODE_FORMATS,
|
|
232
|
+
verbose: false,
|
|
233
|
+
});
|
|
205
234
|
});
|
|
206
235
|
|
|
207
|
-
it('
|
|
208
|
-
|
|
236
|
+
it('decodes a centered QR code from the raw camera frame fast path', () => {
|
|
237
|
+
jsQrMocks.default.mockReturnValueOnce({ data: 'FAST-QR' });
|
|
238
|
+
stubCanvas();
|
|
239
|
+
const video = document.createElement('video');
|
|
240
|
+
Object.defineProperties(video, {
|
|
241
|
+
clientHeight: { value: 720 },
|
|
242
|
+
clientWidth: { value: 1280 },
|
|
243
|
+
readyState: { value: HTMLMediaElement.HAVE_CURRENT_DATA },
|
|
244
|
+
videoHeight: { value: 1080 },
|
|
245
|
+
videoWidth: { value: 1920 },
|
|
246
|
+
});
|
|
247
|
+
const canvas = document.createElement('canvas');
|
|
248
|
+
|
|
249
|
+
expect(scanQrVideoFrame(video, canvas)).toBe('FAST-QR');
|
|
209
250
|
|
|
210
|
-
|
|
251
|
+
const context = canvas.getContext('2d');
|
|
252
|
+
expect(context?.drawImage).toHaveBeenCalledWith(video, 96, 162, 1728, 756, 0, 0, 960, 420);
|
|
253
|
+
expect(jsQrMocks.default).toHaveBeenCalledWith(expect.any(Uint8ClampedArray), 960, 420, {
|
|
254
|
+
inversionAttempts: 'dontInvert',
|
|
255
|
+
});
|
|
256
|
+
});
|
|
211
257
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
258
|
+
it('enables continuous camera focus when the device supports it', async () => {
|
|
259
|
+
mocks.getRunningTrackCapabilities.mockReturnValue({ focusMode: ['manual', 'continuous'] });
|
|
260
|
+
|
|
261
|
+
render(<ScannerHost />);
|
|
215
262
|
|
|
216
|
-
|
|
263
|
+
await waitFor(() =>
|
|
264
|
+
expect(mocks.applyVideoConstraints).toHaveBeenCalledWith({
|
|
265
|
+
advanced: [{ focusMode: 'continuous' }],
|
|
266
|
+
}),
|
|
267
|
+
);
|
|
217
268
|
});
|
|
218
269
|
|
|
219
270
|
it('reports camera start failures through the dedicated handler', async () => {
|
|
@@ -228,6 +279,51 @@ describe('useCodeScanner', () => {
|
|
|
228
279
|
expect(handleScanFailure).not.toHaveBeenCalled();
|
|
229
280
|
});
|
|
230
281
|
|
|
282
|
+
it('stops a camera that finishes starting after the scanner unmounts', async () => {
|
|
283
|
+
let resolveStart: ((value: null) => void) | undefined;
|
|
284
|
+
mocks.start.mockReturnValueOnce(
|
|
285
|
+
new Promise<null>((resolve) => {
|
|
286
|
+
resolveStart = resolve;
|
|
287
|
+
}),
|
|
288
|
+
);
|
|
289
|
+
mocks.getState.mockReturnValueOnce(1).mockReturnValue(2);
|
|
290
|
+
|
|
291
|
+
const { unmount } = render(<ScannerHost />);
|
|
292
|
+
await waitFor(() => expect(mocks.start).toHaveBeenCalled());
|
|
293
|
+
|
|
294
|
+
unmount();
|
|
295
|
+
await act(async () => {
|
|
296
|
+
resolveStart?.(null);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
await waitFor(() => expect(mocks.stop).toHaveBeenCalledTimes(1));
|
|
300
|
+
expect(mocks.clear).toHaveBeenCalled();
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('ignores a camera start failure after the scanner unmounts', async () => {
|
|
304
|
+
let rejectStart: ((error: Error) => void) | undefined;
|
|
305
|
+
mocks.start.mockReturnValueOnce(
|
|
306
|
+
new Promise<null>((_resolve, reject) => {
|
|
307
|
+
rejectStart = reject;
|
|
308
|
+
}),
|
|
309
|
+
);
|
|
310
|
+
const handleCameraStartFailure = vi.fn();
|
|
311
|
+
const handleScanFailure = vi.fn();
|
|
312
|
+
|
|
313
|
+
const { unmount } = render(
|
|
314
|
+
<ScannerHost onCameraStartFailure={handleCameraStartFailure} onScanFailure={handleScanFailure} />,
|
|
315
|
+
);
|
|
316
|
+
await waitFor(() => expect(mocks.start).toHaveBeenCalled());
|
|
317
|
+
|
|
318
|
+
unmount();
|
|
319
|
+
await act(async () => {
|
|
320
|
+
rejectStart?.(new Error('Camera start canceled'));
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
expect(handleCameraStartFailure).not.toHaveBeenCalled();
|
|
324
|
+
expect(handleScanFailure).not.toHaveBeenCalled();
|
|
325
|
+
});
|
|
326
|
+
|
|
231
327
|
it('uses jsQR first for Safari uploaded QR images', async () => {
|
|
232
328
|
const handleScanSuccess = vi.fn();
|
|
233
329
|
jsQrMocks.default.mockReturnValueOnce({ data: 'JSQR-CODE' });
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { Html5Qrcode, Html5QrcodeScannerState, Html5QrcodeSupportedFormats } from 'html5-qrcode';
|
|
11
11
|
import jsQR from 'jsqr';
|
|
12
|
-
import { useCallback, useEffect, useState } from 'react';
|
|
12
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
13
13
|
import type { CodeFormatsToSupport } from './types';
|
|
14
14
|
|
|
15
15
|
type ScannerSize = {
|
|
@@ -21,7 +21,6 @@ type UseCodeScannerOptions = {
|
|
|
21
21
|
enabled: boolean;
|
|
22
22
|
elementId: string;
|
|
23
23
|
formatsToSupport?: CodeFormatsToSupport;
|
|
24
|
-
scanBoxSize?: ScannerSize;
|
|
25
24
|
onScannerSizeChanged?: (size: ScannerSize) => void;
|
|
26
25
|
onScanSuccess: (text: string) => void;
|
|
27
26
|
onScanFailure?: () => void;
|
|
@@ -38,7 +37,18 @@ type JsQRImageTransform = {
|
|
|
38
37
|
threshold?: number;
|
|
39
38
|
};
|
|
40
39
|
|
|
40
|
+
type FocusMediaTrackCapabilities = MediaTrackCapabilities & {
|
|
41
|
+
focusMode?: string[];
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
type FocusMediaTrackConstraintSet = MediaTrackConstraintSet & {
|
|
45
|
+
focusMode?: string;
|
|
46
|
+
};
|
|
47
|
+
|
|
41
48
|
const QR_SCAN_IMAGE_SIZES = [3200, 2400, 1600, 1000];
|
|
49
|
+
const LIVE_QR_SCAN_INTERVAL = 120;
|
|
50
|
+
const LIVE_QR_SCAN_MAX_WIDTH = 960;
|
|
51
|
+
const LIVE_QR_SCAN_MAX_HEIGHT = 540;
|
|
42
52
|
const QR_SCAN_IMAGE_TRANSFORMS: JsQRImageTransform[] = [
|
|
43
53
|
{},
|
|
44
54
|
{ contrast: 3, threshold: 105 },
|
|
@@ -66,8 +76,72 @@ export const DEFAULT_CODE_FORMATS: CodeFormatsToSupport = [
|
|
|
66
76
|
|
|
67
77
|
export function getCodeScanBoxSize(width: number, height: number) {
|
|
68
78
|
return {
|
|
69
|
-
width: Math.floor(Math.min(width *
|
|
70
|
-
height: Math.floor(Math.min(height *
|
|
79
|
+
width: Math.floor(Math.min((width * 90) / 100, 1152)),
|
|
80
|
+
height: Math.floor(Math.min((height * 70) / 100, 540)),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function scanQrVideoFrame(video: HTMLVideoElement, canvas: HTMLCanvasElement) {
|
|
85
|
+
if (!video.videoWidth || !video.videoHeight || video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const viewfinderWidth = video.clientWidth || video.videoWidth;
|
|
90
|
+
const viewfinderHeight = video.clientHeight || video.videoHeight;
|
|
91
|
+
const scanBoxSize = getCodeScanBoxSize(viewfinderWidth, viewfinderHeight);
|
|
92
|
+
const sourceWidth = Math.min(video.videoWidth, Math.floor(scanBoxSize.width * (video.videoWidth / viewfinderWidth)));
|
|
93
|
+
const sourceHeight = Math.min(
|
|
94
|
+
video.videoHeight,
|
|
95
|
+
Math.floor(scanBoxSize.height * (video.videoHeight / viewfinderHeight)),
|
|
96
|
+
);
|
|
97
|
+
const sourceX = Math.floor((video.videoWidth - sourceWidth) / 2);
|
|
98
|
+
const sourceY = Math.floor((video.videoHeight - sourceHeight) / 2);
|
|
99
|
+
const targetScale = Math.min(1, LIVE_QR_SCAN_MAX_WIDTH / sourceWidth, LIVE_QR_SCAN_MAX_HEIGHT / sourceHeight);
|
|
100
|
+
const targetWidth = Math.max(1, Math.floor(sourceWidth * targetScale));
|
|
101
|
+
const targetHeight = Math.max(1, Math.floor(sourceHeight * targetScale));
|
|
102
|
+
if (canvas.width !== targetWidth) {
|
|
103
|
+
canvas.width = targetWidth;
|
|
104
|
+
}
|
|
105
|
+
if (canvas.height !== targetHeight) {
|
|
106
|
+
canvas.height = targetHeight;
|
|
107
|
+
}
|
|
108
|
+
const context = canvas.getContext('2d', { willReadFrequently: true });
|
|
109
|
+
if (!context) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
context.drawImage(video, sourceX, sourceY, sourceWidth, sourceHeight, 0, 0, targetWidth, targetHeight);
|
|
114
|
+
const imageData = context.getImageData(0, 0, targetWidth, targetHeight);
|
|
115
|
+
return jsQR(imageData.data, imageData.width, imageData.height, { inversionAttempts: 'dontInvert' })?.data;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function startLiveQrScan(elementId: string, onScanSuccess: (text: string) => void) {
|
|
119
|
+
const canvas = document.createElement('canvas');
|
|
120
|
+
let timer: number | undefined;
|
|
121
|
+
let stopped = false;
|
|
122
|
+
|
|
123
|
+
const scan = () => {
|
|
124
|
+
if (stopped) {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const video = document.getElementById(elementId)?.querySelector('video');
|
|
128
|
+
if (video) {
|
|
129
|
+
const decodedText = scanQrVideoFrame(video, canvas);
|
|
130
|
+
if (decodedText) {
|
|
131
|
+
stopped = true;
|
|
132
|
+
onScanSuccess(decodedText);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
timer = window.setTimeout(scan, LIVE_QR_SCAN_INTERVAL);
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
timer = window.setTimeout(scan, 0);
|
|
140
|
+
return () => {
|
|
141
|
+
stopped = true;
|
|
142
|
+
if (timer !== undefined) {
|
|
143
|
+
window.clearTimeout(timer);
|
|
144
|
+
}
|
|
71
145
|
};
|
|
72
146
|
}
|
|
73
147
|
|
|
@@ -92,6 +166,19 @@ async function stopScanner(scanner?: Html5Qrcode, options: { clear?: boolean } =
|
|
|
92
166
|
}
|
|
93
167
|
}
|
|
94
168
|
|
|
169
|
+
async function enableContinuousFocus(scanner: Html5Qrcode) {
|
|
170
|
+
try {
|
|
171
|
+
const capabilities = scanner.getRunningTrackCapabilities() as FocusMediaTrackCapabilities;
|
|
172
|
+
if (!capabilities.focusMode?.includes('continuous')) {
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const focusConstraints: FocusMediaTrackConstraintSet = { focusMode: 'continuous' };
|
|
176
|
+
await scanner.applyVideoConstraints({ advanced: [focusConstraints] });
|
|
177
|
+
} catch {
|
|
178
|
+
// Some browsers expose incomplete camera capability APIs. Scanning should continue without explicit focus control.
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
95
182
|
function isSafariBrowser() {
|
|
96
183
|
const { userAgent, vendor } = navigator;
|
|
97
184
|
return /Apple/i.test(vendor) && /Safari/i.test(userAgent) && !/CriOS|FxiOS|EdgiOS|Chrome/i.test(userAgent);
|
|
@@ -216,40 +303,95 @@ export function useCodeScanner({
|
|
|
216
303
|
enabled,
|
|
217
304
|
elementId,
|
|
218
305
|
formatsToSupport,
|
|
219
|
-
scanBoxSize,
|
|
220
306
|
onScannerSizeChanged,
|
|
221
307
|
onScanSuccess,
|
|
222
308
|
onScanFailure,
|
|
223
309
|
onCameraStartFailure,
|
|
224
310
|
}: UseCodeScannerOptions) {
|
|
225
311
|
const [scanner, setScanner] = useState<Html5Qrcode>();
|
|
312
|
+
const liveQrScanStopRef = useRef<() => void>();
|
|
313
|
+
const scanSucceededRef = useRef(false);
|
|
314
|
+
const scanSessionRef = useRef(0);
|
|
315
|
+
|
|
316
|
+
const stopLiveQrScan = useCallback(() => {
|
|
317
|
+
liveQrScanStopRef.current?.();
|
|
318
|
+
liveQrScanStopRef.current = undefined;
|
|
319
|
+
}, []);
|
|
320
|
+
|
|
321
|
+
const cancelActiveScan = useCallback(() => {
|
|
322
|
+
scanSessionRef.current += 1;
|
|
323
|
+
stopLiveQrScan();
|
|
324
|
+
}, [stopLiveQrScan]);
|
|
325
|
+
|
|
326
|
+
const reportScanSuccess = useCallback(
|
|
327
|
+
(text: string) => {
|
|
328
|
+
if (scanSucceededRef.current) {
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
scanSucceededRef.current = true;
|
|
332
|
+
stopLiveQrScan();
|
|
333
|
+
onScanSuccess(text);
|
|
334
|
+
},
|
|
335
|
+
[onScanSuccess, stopLiveQrScan],
|
|
336
|
+
);
|
|
226
337
|
|
|
227
338
|
const startScanCamera = useCallback(
|
|
228
339
|
async (scannerInstance: Html5Qrcode) => {
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
340
|
+
cancelActiveScan();
|
|
341
|
+
const scanSession = scanSessionRef.current;
|
|
342
|
+
scanSucceededRef.current = false;
|
|
343
|
+
try {
|
|
344
|
+
await scannerInstance.start(
|
|
345
|
+
{ facingMode: 'environment' },
|
|
346
|
+
{
|
|
347
|
+
fps: 8,
|
|
348
|
+
disableFlip: false,
|
|
349
|
+
videoConstraints: {
|
|
350
|
+
facingMode: { ideal: 'environment' },
|
|
351
|
+
width: { ideal: 1920 },
|
|
352
|
+
height: { ideal: 1080 },
|
|
353
|
+
frameRate: { ideal: 30 },
|
|
354
|
+
},
|
|
355
|
+
qrbox(width, height) {
|
|
356
|
+
onScannerSizeChanged?.({ width, height });
|
|
357
|
+
return clampCodeScanBoxSize(getCodeScanBoxSize(width, height), width, height);
|
|
358
|
+
},
|
|
359
|
+
},
|
|
360
|
+
(decodedText) => {
|
|
361
|
+
reportScanSuccess(decodedText);
|
|
236
362
|
},
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
363
|
+
undefined,
|
|
364
|
+
);
|
|
365
|
+
} catch (error) {
|
|
366
|
+
if (scanSession !== scanSessionRef.current) {
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
throw error;
|
|
370
|
+
}
|
|
371
|
+
if (scanSession !== scanSessionRef.current) {
|
|
372
|
+
try {
|
|
373
|
+
await stopScanner(scannerInstance, { clear: true });
|
|
374
|
+
} catch {
|
|
375
|
+
// The scanner may already have been cleared by the canceled session cleanup.
|
|
376
|
+
}
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
if (shouldScanQrWithJsQR(formatsToSupport)) {
|
|
380
|
+
liveQrScanStopRef.current = startLiveQrScan(elementId, reportScanSuccess);
|
|
381
|
+
}
|
|
382
|
+
await enableContinuousFocus(scannerInstance);
|
|
243
383
|
},
|
|
244
|
-
[
|
|
384
|
+
[cancelActiveScan, elementId, formatsToSupport, onScannerSizeChanged, reportScanSuccess],
|
|
245
385
|
);
|
|
246
386
|
|
|
247
387
|
const startScanFile = useCallback(
|
|
248
388
|
async (file: File) => {
|
|
389
|
+
cancelActiveScan();
|
|
390
|
+
scanSucceededRef.current = false;
|
|
249
391
|
if (isSafariBrowser() && shouldScanQrWithJsQR(formatsToSupport)) {
|
|
250
392
|
try {
|
|
251
393
|
const decodedText = await scanFileWithJsQR(file, formatsToSupport);
|
|
252
|
-
|
|
394
|
+
reportScanSuccess(decodedText);
|
|
253
395
|
return;
|
|
254
396
|
} catch {
|
|
255
397
|
// Fall through to html5-qrcode so barcode uploads still work in Safari.
|
|
@@ -263,13 +405,13 @@ export function useCodeScanner({
|
|
|
263
405
|
await stopScanner(scanner);
|
|
264
406
|
try {
|
|
265
407
|
const result = await scanner.scanFileV2(file, false);
|
|
266
|
-
|
|
408
|
+
reportScanSuccess(result.decodedText);
|
|
267
409
|
} catch {
|
|
268
410
|
onScanFailure?.();
|
|
269
411
|
await startScanCamera(scanner);
|
|
270
412
|
}
|
|
271
413
|
},
|
|
272
|
-
[formatsToSupport, onScanFailure,
|
|
414
|
+
[cancelActiveScan, formatsToSupport, onScanFailure, reportScanSuccess, scanner, startScanCamera],
|
|
273
415
|
);
|
|
274
416
|
|
|
275
417
|
useEffect(() => {
|
|
@@ -291,9 +433,10 @@ export function useCodeScanner({
|
|
|
291
433
|
});
|
|
292
434
|
|
|
293
435
|
return () => {
|
|
436
|
+
cancelActiveScan();
|
|
294
437
|
stopScanner(scannerInstance, { clear: true }).catch(() => undefined);
|
|
295
438
|
};
|
|
296
|
-
}, [elementId, enabled, formatsToSupport, onCameraStartFailure, onScanFailure, startScanCamera]);
|
|
439
|
+
}, [cancelActiveScan, elementId, enabled, formatsToSupport, onCameraStartFailure, onScanFailure, startScanCamera]);
|
|
297
440
|
|
|
298
441
|
return {
|
|
299
442
|
startScanFile,
|
package/src/components/index.ts
CHANGED
|
@@ -116,6 +116,7 @@ describe('FlowRoute', () => {
|
|
|
116
116
|
}),
|
|
117
117
|
);
|
|
118
118
|
});
|
|
119
|
+
expect(engine.context.deviceType).toBe('computer');
|
|
119
120
|
|
|
120
121
|
rerender(
|
|
121
122
|
<FlowEngineProvider engine={engine}>
|
|
@@ -581,14 +582,14 @@ describe('FlowRoute', () => {
|
|
|
581
582
|
expect(getRouteBySchemaUid).toHaveBeenCalledTimes(1);
|
|
582
583
|
});
|
|
583
584
|
|
|
584
|
-
it('should
|
|
585
|
+
it('should explain how to open a legacy page outside the v2 runtime', async () => {
|
|
585
586
|
const originalLocation = window.location;
|
|
586
587
|
const replace = vi.fn();
|
|
587
588
|
Object.defineProperty(window, 'location', {
|
|
588
589
|
configurable: true,
|
|
589
590
|
value: {
|
|
590
591
|
...originalLocation,
|
|
591
|
-
pathname: '/v2/admin/test-page/tab/tab-1',
|
|
592
|
+
pathname: '/nocobase/v2/apps/jhb20/admin/test-page/tab/tab-1',
|
|
592
593
|
search: '?from=direct',
|
|
593
594
|
hash: '#dialog',
|
|
594
595
|
replace,
|
|
@@ -607,9 +608,9 @@ describe('FlowRoute', () => {
|
|
|
607
608
|
});
|
|
608
609
|
engine.context.defineProperty('app', {
|
|
609
610
|
value: {
|
|
610
|
-
getPublicPath: () => '/v2/',
|
|
611
|
+
getPublicPath: () => '/nocobase/v2/',
|
|
611
612
|
router: {
|
|
612
|
-
getBasename: () => '/v2',
|
|
613
|
+
getBasename: () => '/nocobase/v2',
|
|
613
614
|
},
|
|
614
615
|
},
|
|
615
616
|
});
|
|
@@ -625,15 +626,25 @@ describe('FlowRoute', () => {
|
|
|
625
626
|
|
|
626
627
|
render(
|
|
627
628
|
<FlowEngineProvider engine={engine}>
|
|
628
|
-
<MemoryRouter initialEntries={['/
|
|
629
|
+
<MemoryRouter initialEntries={['/nocobase/v2/apps/jhb20/admin/test-page/tab/tab-1?from=direct#dialog']}>
|
|
629
630
|
<Routes>
|
|
630
|
-
<Route path="/
|
|
631
|
+
<Route path="/nocobase/v2/apps/jhb20/admin/:name/*" element={<FlowRoute />} />
|
|
631
632
|
</Routes>
|
|
632
633
|
</MemoryRouter>
|
|
633
634
|
</FlowEngineProvider>,
|
|
634
635
|
);
|
|
635
636
|
|
|
636
|
-
expect(await screen.findByText('
|
|
637
|
+
expect(await screen.findByText('This page is not supported in the /v2/ branch')).toBeInTheDocument();
|
|
638
|
+
expect(
|
|
639
|
+
screen.getByText(
|
|
640
|
+
'The /v2/ branch only supports new pages. This page is a legacy page. Please open it from the original entry.',
|
|
641
|
+
),
|
|
642
|
+
).toBeInTheDocument();
|
|
643
|
+
expect(screen.queryByText('404')).not.toBeInTheDocument();
|
|
644
|
+
expect(screen.getByRole('link', { name: 'Open from the original entry' })).toHaveAttribute(
|
|
645
|
+
'href',
|
|
646
|
+
'/nocobase/apps/jhb20/admin/test-page/tab/tab-1?from=direct#dialog',
|
|
647
|
+
);
|
|
637
648
|
expect(replace).not.toHaveBeenCalled();
|
|
638
649
|
expect(adminLayoutModel.registerRoutePage).not.toHaveBeenCalled();
|
|
639
650
|
expect(adminLayoutModel.updateRoutePage).not.toHaveBeenCalled();
|
|
@@ -706,6 +717,113 @@ describe('FlowRoute', () => {
|
|
|
706
717
|
}
|
|
707
718
|
});
|
|
708
719
|
|
|
720
|
+
it('should render 404 for missing FlowModel in notFound mode without routeRepository', async () => {
|
|
721
|
+
const engine = new FlowEngine();
|
|
722
|
+
engine.context.defineProperty('app', {
|
|
723
|
+
value: {
|
|
724
|
+
getPublicPath: () => '/v2/',
|
|
725
|
+
router: {
|
|
726
|
+
getBasename: () => '/v2',
|
|
727
|
+
},
|
|
728
|
+
},
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
const adminLayoutModel: MockAdminLayoutModel = Object.assign(
|
|
732
|
+
engine.createModel({ uid: 'admin-layout-model', use: 'FlowModel' }),
|
|
733
|
+
{
|
|
734
|
+
registerRoutePage: vi.fn(),
|
|
735
|
+
updateRoutePage: vi.fn(),
|
|
736
|
+
unregisterRoutePage: vi.fn(),
|
|
737
|
+
},
|
|
738
|
+
);
|
|
739
|
+
|
|
740
|
+
render(
|
|
741
|
+
<FlowEngineProvider engine={engine}>
|
|
742
|
+
<MemoryRouter initialEntries={['/embed/missing-page']}>
|
|
743
|
+
<Routes>
|
|
744
|
+
<Route path="/embed/:name" element={<FlowRoute legacyPageBehavior="notFound" />} />
|
|
745
|
+
</Routes>
|
|
746
|
+
</MemoryRouter>
|
|
747
|
+
</FlowEngineProvider>,
|
|
748
|
+
);
|
|
749
|
+
|
|
750
|
+
expect(await screen.findByText('404')).toBeInTheDocument();
|
|
751
|
+
expect(adminLayoutModel.registerRoutePage).not.toHaveBeenCalled();
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
it('should bridge by default when routeRepository does not exist', async () => {
|
|
755
|
+
const engine = new FlowEngine();
|
|
756
|
+
engine.context.defineProperty('app', {
|
|
757
|
+
value: {
|
|
758
|
+
getPublicPath: () => '/v2/',
|
|
759
|
+
router: {
|
|
760
|
+
getBasename: () => '/v2',
|
|
761
|
+
},
|
|
762
|
+
},
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
const adminLayoutModel: MockAdminLayoutModel = Object.assign(
|
|
766
|
+
engine.createModel({ uid: 'admin-layout-model', use: 'FlowModel' }),
|
|
767
|
+
{
|
|
768
|
+
registerRoutePage: vi.fn(),
|
|
769
|
+
updateRoutePage: vi.fn(),
|
|
770
|
+
unregisterRoutePage: vi.fn(),
|
|
771
|
+
},
|
|
772
|
+
);
|
|
773
|
+
|
|
774
|
+
render(
|
|
775
|
+
<FlowEngineProvider engine={engine}>
|
|
776
|
+
<MemoryRouter initialEntries={['/flow/missing-page']}>
|
|
777
|
+
<Routes>
|
|
778
|
+
<Route path="/flow/:name" element={<FlowRoute />} />
|
|
779
|
+
</Routes>
|
|
780
|
+
</MemoryRouter>
|
|
781
|
+
</FlowEngineProvider>,
|
|
782
|
+
);
|
|
783
|
+
|
|
784
|
+
await waitFor(() => {
|
|
785
|
+
expect(adminLayoutModel.registerRoutePage).toHaveBeenCalledWith('missing-page', expect.any(Object));
|
|
786
|
+
});
|
|
787
|
+
expect(screen.queryByText('404')).not.toBeInTheDocument();
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
it('should bridge existing FlowModel in notFound mode without routeRepository', async () => {
|
|
791
|
+
const engine = new FlowEngine();
|
|
792
|
+
engine.createModel({ uid: 'test-page', use: 'FlowModel' });
|
|
793
|
+
engine.context.defineProperty('app', {
|
|
794
|
+
value: {
|
|
795
|
+
getPublicPath: () => '/v2/',
|
|
796
|
+
router: {
|
|
797
|
+
getBasename: () => '/v2',
|
|
798
|
+
},
|
|
799
|
+
},
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
const adminLayoutModel: MockAdminLayoutModel = Object.assign(
|
|
803
|
+
engine.createModel({ uid: 'admin-layout-model', use: 'FlowModel' }),
|
|
804
|
+
{
|
|
805
|
+
registerRoutePage: vi.fn(),
|
|
806
|
+
updateRoutePage: vi.fn(),
|
|
807
|
+
unregisterRoutePage: vi.fn(),
|
|
808
|
+
},
|
|
809
|
+
);
|
|
810
|
+
|
|
811
|
+
render(
|
|
812
|
+
<FlowEngineProvider engine={engine}>
|
|
813
|
+
<MemoryRouter initialEntries={['/embed/test-page']}>
|
|
814
|
+
<Routes>
|
|
815
|
+
<Route path="/embed/:name" element={<FlowRoute legacyPageBehavior="notFound" />} />
|
|
816
|
+
</Routes>
|
|
817
|
+
</MemoryRouter>
|
|
818
|
+
</FlowEngineProvider>,
|
|
819
|
+
);
|
|
820
|
+
|
|
821
|
+
await waitFor(() => {
|
|
822
|
+
expect(adminLayoutModel.registerRoutePage).toHaveBeenCalledWith('test-page', expect.any(Object));
|
|
823
|
+
});
|
|
824
|
+
expect(screen.queryByText('404')).not.toBeInTheDocument();
|
|
825
|
+
});
|
|
826
|
+
|
|
709
827
|
it('should bridge existing FlowModel when behavior is notFound and routeRepository has no route', async () => {
|
|
710
828
|
const engine = new FlowEngine();
|
|
711
829
|
engine.setModelRepository({
|
|
@@ -1801,7 +1919,7 @@ describe('FlowRoute', () => {
|
|
|
1801
1919
|
}
|
|
1802
1920
|
});
|
|
1803
1921
|
|
|
1804
|
-
it('should render
|
|
1922
|
+
it('should render 404 when route and FlowModel do not exist', async () => {
|
|
1805
1923
|
const originalLocation = window.location;
|
|
1806
1924
|
const replace = vi.fn();
|
|
1807
1925
|
Object.defineProperty(window, 'location', {
|