@nocobase/client-v2 2.2.0-alpha.7 → 2.2.0-beta.10

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 (50) hide show
  1. package/es/BaseApplication.d.ts +1 -1
  2. package/es/components/form/TypedVariableInput.d.ts +5 -31
  3. package/es/components/form/filter/CollectionFilter.d.ts +0 -2
  4. package/es/components/form/filter/CollectionFilterItem.d.ts +1 -11
  5. package/es/components/form/filter/CollectionFilterPanel.d.ts +0 -2
  6. package/es/components/form/filter/index.d.ts +0 -2
  7. package/es/components/form/filter/useFilterActionProps.d.ts +0 -2
  8. package/es/components/index.d.ts +0 -1
  9. package/es/flow/components/FieldAssignExactDatePicker.d.ts +0 -1
  10. package/es/flow/components/FieldAssignValueInput.d.ts +0 -3
  11. package/es/flow/components/RunJSValueEditor.d.ts +0 -1
  12. package/es/flow/index.d.ts +0 -1
  13. package/es/index.d.ts +0 -4
  14. package/es/index.mjs +99 -133
  15. package/es/utils/index.d.ts +0 -1
  16. package/lib/index.js +106 -140
  17. package/package.json +7 -8
  18. package/src/BaseApplication.tsx +6 -6
  19. package/src/__tests__/app.test.tsx +0 -26
  20. package/src/__tests__/browserChecker.test.ts +0 -61
  21. package/src/components/README.md +1 -7
  22. package/src/components/README.zh-CN.md +1 -6
  23. package/src/components/form/JsonTextArea.tsx +0 -4
  24. package/src/components/form/ScanInput/__tests__/useCodeScanner.test.tsx +2 -145
  25. package/src/components/form/ScanInput/useCodeScanner.ts +2 -154
  26. package/src/components/form/TypedVariableInput.tsx +98 -445
  27. package/src/components/form/__tests__/TypedVariableInput.test.tsx +9 -320
  28. package/src/components/form/filter/CollectionFilter.tsx +0 -4
  29. package/src/components/form/filter/CollectionFilterItem.tsx +7 -32
  30. package/src/components/form/filter/CollectionFilterPanel.tsx +0 -4
  31. package/src/components/form/filter/__tests__/CollectionFilterItem.test.tsx +0 -38
  32. package/src/components/form/filter/index.ts +0 -2
  33. package/src/components/form/filter/useFilterActionProps.ts +6 -37
  34. package/src/components/index.ts +0 -1
  35. package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +1 -0
  36. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutComponent.test.tsx +6 -253
  37. package/src/flow/components/FieldAssignExactDatePicker.tsx +11 -25
  38. package/src/flow/components/FieldAssignValueInput.tsx +9 -55
  39. package/src/flow/components/RunJSValueEditor.tsx +1 -9
  40. package/src/flow/index.ts +0 -1
  41. package/src/index.ts +0 -4
  42. package/src/utils/index.tsx +0 -1
  43. package/es/components/category-tabs/SortableCategoryTabs.d.ts +0 -55
  44. package/es/components/category-tabs/index.d.ts +0 -9
  45. package/es/utils/getRouteRuntimeVersion.d.ts +0 -23
  46. package/src/__tests__/getRouteRuntimeVersion.test.ts +0 -68
  47. package/src/components/category-tabs/SortableCategoryTabs.tsx +0 -210
  48. package/src/components/category-tabs/index.ts +0 -10
  49. package/src/components/form/filter/__tests__/useFilterActionProps.test.tsx +0 -95
  50. package/src/utils/getRouteRuntimeVersion.ts +0 -185
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nocobase/client-v2",
3
- "version": "2.2.0-alpha.7",
3
+ "version": "2.2.0-beta.10",
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.2.0-alpha.7",
31
- "@nocobase/flow-engine": "2.2.0-alpha.7",
32
- "@nocobase/sdk": "2.2.0-alpha.7",
33
- "@nocobase/shared": "2.2.0-alpha.7",
34
- "@nocobase/utils": "2.2.0-alpha.7",
30
+ "@nocobase/evaluators": "2.2.0-beta.10",
31
+ "@nocobase/flow-engine": "2.2.0-beta.10",
32
+ "@nocobase/sdk": "2.2.0-beta.10",
33
+ "@nocobase/shared": "2.2.0-beta.10",
34
+ "@nocobase/utils": "2.2.0-beta.10",
35
35
  "ahooks": "^3.7.2",
36
36
  "antd": "5.24.2",
37
37
  "antd-style": "3.7.1",
@@ -42,10 +42,9 @@
42
42
  "html5-qrcode": "^2.3.8",
43
43
  "i18next": "^22.4.9",
44
44
  "json5": "^2.2.3",
45
- "jsqr": "^1.4.0",
46
45
  "lodash": "4.17.21",
47
46
  "react-i18next": "^11.15.1",
48
47
  "react-router-dom": "^6.30.1"
49
48
  },
50
- "gitHead": "34ba02960fca9ab0b6881d3db7040a8a64067bba"
49
+ "gitHead": "d572beec24de46df948f28db72b25303a63bf62d"
51
50
  }
@@ -81,10 +81,6 @@ const trimTrailingSlashes = (value: string) => {
81
81
  return match ? value.slice(0, -match[0].length) : value;
82
82
  };
83
83
 
84
- const ensureTrailingSlash = (value: string) => {
85
- return `${trimTrailingSlashes(value)}/`;
86
- };
87
-
88
84
  const isRenderableComponentType = (value: unknown): value is AnyComponent => isValidElementType(value);
89
85
 
90
86
  export type DevDynamicImport = (packageName: string) => Promise<{ default: PluginClass }>;
@@ -438,11 +434,15 @@ export abstract class BaseApplication<
438
434
  }
439
435
 
440
436
  getCdnUrl() {
441
- return ensureTrailingSlash(window['__webpack_public_path__'] || this.getPublicPath());
437
+ return window['__webpack_public_path__'] || this.getPublicPath();
442
438
  }
443
439
 
444
440
  getPublicPath() {
445
- return ensureTrailingSlash(this.options.publicPath || '/');
441
+ let publicPath = this.options.publicPath || '/';
442
+ if (!publicPath.endsWith('/')) {
443
+ publicPath += '/';
444
+ }
445
+ return publicPath;
446
446
  }
447
447
 
448
448
  getApiUrl(pathname = '') {
@@ -34,7 +34,6 @@ 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
37
  vi.restoreAllMocks();
39
38
  });
40
39
 
@@ -59,31 +58,6 @@ describe('app', () => {
59
58
  expect(app.jsonLogic.apply({ $testAlwaysTrue: [] })).toBe(true);
60
59
  });
61
60
 
62
- it('should normalize publicPath and webpack public path with a single trailing slash', () => {
63
- const app = new Application({
64
- router,
65
- publicPath: '/admin//',
66
- });
67
-
68
- expect(app.getPublicPath()).toBe('/admin/');
69
-
70
- window['__webpack_public_path__'] = '/cdn/assets///';
71
- expect(app.getCdnUrl()).toBe('/cdn/assets/');
72
-
73
- delete window['__webpack_public_path__'];
74
- expect(app.getCdnUrl()).toBe('/admin/');
75
- });
76
-
77
- it('should normalize webpack public path without a trailing slash', () => {
78
- const app = new Application({
79
- router,
80
- publicPath: '/admin/',
81
- });
82
-
83
- window['__webpack_public_path__'] = '/cdn/assets';
84
- expect(app.getCdnUrl()).toBe('/cdn/assets/');
85
- });
86
-
87
61
  it('should apply the provided favicon immediately', () => {
88
62
  const app = new Application({ router });
89
63
 
@@ -15,8 +15,6 @@ import { fileURLToPath } from 'node:url';
15
15
  type BrowserCheckerCase = {
16
16
  pathname: string;
17
17
  publicPath: string;
18
- modernClientPrefix?: string;
19
- appClientEntryMode?: string;
20
18
  expectedRedirect?: string;
21
19
  };
22
20
 
@@ -42,8 +40,6 @@ function executeBrowserChecker(scriptPath: string, input: BrowserCheckerCase) {
42
40
  showLog: false,
43
41
  window: {
44
42
  __nocobase_public_path__: input.publicPath,
45
- __nocobase_modern_client_prefix__: input.modernClientPrefix,
46
- __nocobase_app_client_entry_mode__: input.appClientEntryMode,
47
43
  location: {
48
44
  origin: 'http://c.local.nocobase.com',
49
45
  pathname: input.pathname,
@@ -113,61 +109,4 @@ describe.each(browserCheckerCases)('$label', ({ scriptPath }) => {
113
109
 
114
110
  expect(replace).not.toHaveBeenCalled();
115
111
  });
116
-
117
- if (scriptPath.includes('/app/client/public/')) {
118
- it('redirects app root to modern entry for modern-default', () => {
119
- const replace = executeBrowserChecker(scriptPath, {
120
- pathname: '/',
121
- publicPath: '/',
122
- modernClientPrefix: 'v',
123
- appClientEntryMode: 'modern-default',
124
- });
125
-
126
- expect(replace).toHaveBeenCalledWith('http://c.local.nocobase.com/v/');
127
- });
128
-
129
- it('does not redirect legacy deep links for modern-default', () => {
130
- const replace = executeBrowserChecker(scriptPath, {
131
- pathname: '/admin',
132
- publicPath: '/',
133
- modernClientPrefix: 'v',
134
- appClientEntryMode: 'modern-default',
135
- });
136
-
137
- expect(replace).not.toHaveBeenCalled();
138
- });
139
-
140
- it('rewrites legacy document paths for modern-only', () => {
141
- const replace = executeBrowserChecker(scriptPath, {
142
- pathname: '/admin/settings/workflow',
143
- publicPath: '/',
144
- modernClientPrefix: 'v',
145
- appClientEntryMode: 'modern-only',
146
- });
147
-
148
- expect(replace).toHaveBeenCalledWith('http://c.local.nocobase.com/v/admin/settings/workflow');
149
- });
150
-
151
- it('redirects sub-path site root directly to final modern target', () => {
152
- const replace = executeBrowserChecker(scriptPath, {
153
- pathname: '/nocobase/',
154
- publicPath: '/nocobase/',
155
- modernClientPrefix: 'v',
156
- appClientEntryMode: 'modern-default',
157
- });
158
-
159
- expect(replace).toHaveBeenCalledWith('http://c.local.nocobase.com/nocobase/v/');
160
- });
161
-
162
- it('rewrites sub-app legacy deep links for modern-only without collapsing the sub-app segment', () => {
163
- const replace = executeBrowserChecker(scriptPath, {
164
- pathname: '/nocobase/apps/a_31itq60q4kg/admin/',
165
- publicPath: '/nocobase/',
166
- modernClientPrefix: 'v',
167
- appClientEntryMode: 'modern-only',
168
- });
169
-
170
- expect(replace).toHaveBeenCalledWith('http://c.local.nocobase.com/nocobase/v/apps/a_31itq60q4kg/admin');
171
- });
172
- }
173
112
  });
@@ -190,10 +190,6 @@ import { TypedVariableInput } from '@nocobase/client-v2';
190
190
  <Form.Item name={['options', 'secure']} label={t('Secure')} initialValue={true}>
191
191
  <TypedVariableInput types={['boolean']} namespaces={['$env']} />
192
192
  </Form.Item>
193
-
194
- // Inject a custom variable tree (e.g. a workflow node's upstream outputs,
195
- // which are not in the global registry)
196
- <TypedVariableInput types={['string', 'number']} metaTree={workflowMetaTree} />
197
193
  ```
198
194
 
199
195
  Key props:
@@ -201,7 +197,6 @@ Key props:
201
197
  - `types`: allowed constant types. Shape mirrors v1 `useTypedConstant` — pass bare type names (`['number', 'boolean']`) or `[type, editorProps]` tuples (`[['number', { min, max, step }]]`) to forward props to the underlying antd editor. Defaults to `['string', 'number', 'boolean', 'date']`. **Even when only one type is allowed, the `Constant` entry still expands into a typed submenu** (Number / Boolean / Date / String) — matches v1 so users can see what type the constant is
202
198
  - `namespaces`: restrict the variable picker to specific top-level namespaces (e.g. `['$env']`). Omit to expose every namespace registered on `flowEngine.context`
203
199
  - `extraNodes`: static leaves appended after the namespace-filtered nodes
204
- - `metaTree`: **inject the variable tree directly** instead of reading the global `flowEngine.context` meta tree. When set, `namespaces`/`extraNodes` are ignored and this tree is used verbatim — for context-scoped variable sources that are not in the global registry (typically a workflow node's upstream outputs, `$jobsMapByNodeKey`). Nodes whose `children` is a thunk (`() => Promise<MetaTreeNode[]>`) are **lazy-loaded on expand** (via flow-engine's `loadMetaTreeChildren`)
205
200
  - `nullable`: whether to expose the `Null` switcher entry. Default `true`. Combined with `Form.Item.rules={[{ required: true }]}`, the user can explicitly clear the field but submission is still blocked by validation — mirrors v1's "Null + required" pairing
206
201
  - `delimiters`: variable-token delimiters, default `['{{', '}}']` — same as `VariableInput`
207
202
  - `value` / `onChange` / `placeholder` / `disabled` / `style` / `className`: standard controlled-input props
@@ -217,10 +212,9 @@ When **not** to use it:
217
212
  - **Pure literal fields** (users will never pass a variable) → use the antd primitive directly (`InputNumber` / `Select` / `DatePicker` / `Input`) and skip the Cascader column overhead
218
213
  - **Pure variable fields** (users will never pass a literal) → use `EnvVariableInput` (`$env`-only, with optional password masking) or `VariableInput` (general-purpose)
219
214
 
220
- Supported constant types: `string` / `number` / `boolean` / `date` / `object`. The `object` (JSON) type renders an inline monospace textarea (2 rows by default, drag-resizable) — it keeps the raw text as a draft while editing and `JSON.parse`s it back into an object on blur. On a parse failure it shows the raw `JSON.parse` message (e.g. `Expected property name or '}' in JSON at position …`, matching v1) on its own row below the input, and does not emit. Mirrors v1 `useTypedConstant`'s object form (default value `{}`).
221
-
222
215
  Capabilities skipped (present in v1, not yet ported to v2):
223
216
 
217
+ - `object` constant type (JSON editor) — v2 has no inline "JSON editor + Cascader switcher" yet; add when there's a concrete caller
224
218
  - Async `loadChildren` cascading — most MetaTree namespaces are already eagerly resolved by `useFilteredMetaTree`, so this hasn't been needed
225
219
 
226
220
  #### FileSizeInput
@@ -190,9 +190,6 @@ import { TypedVariableInput } from '@nocobase/client-v2';
190
190
  <Form.Item name={['options', 'secure']} label={t('安全模式')} initialValue={true}>
191
191
  <TypedVariableInput types={['boolean']} namespaces={['$env']} />
192
192
  </Form.Item>
193
-
194
- // 注入自定义变量树(如工作流节点的上游输出,不在全局注册表里)
195
- <TypedVariableInput types={['string', 'number']} metaTree={workflowMetaTree} />
196
193
  ```
197
194
 
198
195
  主要属性:
@@ -200,7 +197,6 @@ import { TypedVariableInput } from '@nocobase/client-v2';
200
197
  - `types`:允许的常量类型。形态对齐 v1 `useTypedConstant`,可以传裸类型名 `['number', 'boolean']`,也可以传 `[type, editorProps]` 元组 `[['number', { min, max, step }]]` 把 props 透传给底层 antd 编辑器。默认 `['string', 'number', 'boolean', 'date']`。**即使只允许一种类型,「常量」入口也会展开二级菜单**(数字 / 逻辑值 / 日期 / 字符串)——跟 v1 一致,让用户能直观看到当前常量是什么类型
201
198
  - `namespaces`:限定变量 picker 可选的顶层命名空间(如 `['$env']`)。不传就用 `flowEngine.context` 里所有已注册命名空间
202
199
  - `extraNodes`:在命名空间过滤后追加几条静态变量节点
203
- - `metaTree`:**直接注入变量树**,取代读取全局 `flowEngine.context` 的 MetaTree。传了它就**忽略** `namespaces`/`extraNodes`,原样使用这棵树——用于不在全局注册表里的、上下文相关的变量源(典型如工作流节点的上游节点输出 `$jobsMapByNodeKey`)。树里 `children` 为函数(`() => Promise<MetaTreeNode[]>`)的节点会在用户展开 Cascader 时**按需懒加载**(复用 flow-engine 的 `loadMetaTreeChildren`)
204
200
  - `nullable`:是否暴露「空值」入口,默认 `true`。配合 `Form.Item.rules={[{ required: true }]}` 可以让用户能手动清空、但提交时会被校验拦截——跟 v1 的「空值 + required」组合一致
205
201
  - `delimiters`:变量 token 开闭分隔符,默认 `['{{', '}}']`,跟 `VariableInput` 一致
206
202
  - `value` / `onChange` / `placeholder` / `disabled` / `style` / `className`:标准受控字段属性
@@ -216,10 +212,9 @@ import { TypedVariableInput } from '@nocobase/client-v2';
216
212
  - **纯字面量字段**(用户不会想填变量)→ 直接用 antd `InputNumber` / `Select` / `DatePicker` / `Input`,省掉 Cascader 那一格的视觉开销
217
213
  - **纯变量字段**(用户不会想填字面量)→ 用 `EnvVariableInput`(`$env` 专用,带 password mask)或 `VariableInput`(更通用)
218
214
 
219
- 支持的常量类型:`string` / `number` / `boolean` / `date` / `object`。其中 `object`(即 JSON)渲染为一个等宽字体的内联 textarea(默认两行、可拖拽拉伸),编辑时保留原始文本草稿、失焦(blur)时 `JSON.parse` 回写为对象;解析失败则在输入框**下方单独一行**显示原生 `JSON.parse` 的错误信息(如 `Expected property name or '}' in JSON at position …`,对齐 v1)且不回写。对齐 v1 `useTypedConstant` 的 object 形态(默认值 `{}`)。
220
-
221
215
  跳过的能力(v1 有但 v2 还没补):
222
216
 
217
+ - `object` 类型(JSON 编辑器)——v2 还没对应的「内联 JSON 编辑器 + Cascader 切换」组件,等真有需求再补
223
218
  - 异步 `loadChildren` 分支——大多数命名空间的 MetaTree 已经由 `useFilteredMetaTree` 提前展平,没遇到刚需
224
219
 
225
220
  #### FileSizeInput
@@ -117,10 +117,6 @@ const JsonTextAreaComponent = React.forwardRef<TextAreaRef, JsonTextAreaProps>((
117
117
  return (
118
118
  <>
119
119
  <Input.TextArea
120
- autoSize={{
121
- minRows: 5,
122
- maxRows: 10,
123
- }}
124
120
  {...textAreaProps}
125
121
  ref={ref}
126
122
  value={text}
@@ -7,9 +7,9 @@
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 { render, waitFor } from '@testing-library/react';
11
11
  import React, { useCallback } from 'react';
12
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
12
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
13
13
  import { DEFAULT_CODE_FORMATS, getCodeScanBoxSize, useCodeScanner } from '../useCodeScanner';
14
14
 
15
15
  type MockScannerInstance = {
@@ -44,12 +44,6 @@ const mocks = vi.hoisted(() => {
44
44
  };
45
45
  });
46
46
 
47
- const jsQrMocks = vi.hoisted(() => {
48
- return {
49
- default: vi.fn(),
50
- };
51
- });
52
-
53
47
  vi.mock('html5-qrcode', () => ({
54
48
  Html5Qrcode: mocks.Html5Qrcode,
55
49
  Html5QrcodeScannerState: {
@@ -72,8 +66,6 @@ vi.mock('html5-qrcode', () => ({
72
66
  },
73
67
  }));
74
68
 
75
- vi.mock('jsqr', () => jsQrMocks);
76
-
77
69
  function ScannerHost({ scanBoxSize }: { scanBoxSize?: { width: number; height: number } } = {}) {
78
70
  const handleScanSuccess = useCallback(() => undefined, []);
79
71
 
@@ -87,74 +79,9 @@ function ScannerHost({ scanBoxSize }: { scanBoxSize?: { width: number; height: n
87
79
  return <div id="scanner" />;
88
80
  }
89
81
 
90
- function FileScannerHost({ onScanSuccess }: { onScanSuccess: (text: string) => void }) {
91
- const { startScanFile } = useCodeScanner({
92
- elementId: 'scanner',
93
- enabled: true,
94
- onScanSuccess,
95
- });
96
-
97
- return (
98
- <>
99
- <div id="scanner" />
100
- <button onClick={() => startScanFile(new File(['qr'], 'qr.png', { type: 'image/png' }))}>scan file</button>
101
- </>
102
- );
103
- }
104
-
105
- function stubSafariBrowser() {
106
- vi.spyOn(window.navigator, 'vendor', 'get').mockReturnValue('Apple Computer, Inc.');
107
- vi.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue(
108
- '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',
109
- );
110
- }
111
-
112
- function stubImageElement(size: { width: number; height: number } = { width: 600, height: 400 }) {
113
- vi.stubGlobal(
114
- 'Image',
115
- class MockImage {
116
- onabort: ((event: Event) => void) | null = null;
117
- onerror: ((event: Event) => void) | null = null;
118
- onload: (() => void) | null = null;
119
- height = size.height;
120
- naturalHeight = size.height;
121
- naturalWidth = size.width;
122
- width = size.width;
123
-
124
- set src(_value: string) {
125
- this.onload?.();
126
- }
127
- },
128
- );
129
- vi.stubGlobal('URL', {
130
- ...URL,
131
- createObjectURL: vi.fn(() => 'blob:qr'),
132
- revokeObjectURL: vi.fn(),
133
- });
134
- }
135
-
136
- function stubCanvas() {
137
- const canvasContext = {
138
- drawImage: vi.fn(),
139
- getImageData: vi.fn((_x: number, _y: number, width: number, height: number) => ({
140
- data: new Uint8ClampedArray(width * height * 4),
141
- height,
142
- width,
143
- })),
144
- } as unknown as CanvasRenderingContext2D;
145
-
146
- vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(canvasContext);
147
- }
148
-
149
82
  describe('useCodeScanner', () => {
150
83
  beforeEach(() => {
151
84
  vi.clearAllMocks();
152
- jsQrMocks.default.mockReturnValue(undefined);
153
- });
154
-
155
- afterEach(() => {
156
- vi.unstubAllGlobals();
157
- vi.restoreAllMocks();
158
85
  });
159
86
 
160
87
  it('starts scanning with QR code and barcode formats by default', async () => {
@@ -193,74 +120,4 @@ describe('useCodeScanner', () => {
193
120
 
194
121
  expect(config?.qrbox?.(1200, 844)).toEqual({ width: 319, height: 240 });
195
122
  });
196
-
197
- it('uses jsQR first for Safari uploaded QR images', async () => {
198
- const handleScanSuccess = vi.fn();
199
- jsQrMocks.default.mockReturnValueOnce({ data: 'JSQR-CODE' });
200
- stubSafariBrowser();
201
- stubImageElement();
202
- stubCanvas();
203
-
204
- render(<FileScannerHost onScanSuccess={handleScanSuccess} />);
205
- await waitFor(() => expect(mocks.start).toHaveBeenCalled());
206
-
207
- fireEvent.click(screen.getByText('scan file'));
208
-
209
- await waitFor(() => expect(handleScanSuccess).toHaveBeenCalledWith('JSQR-CODE'));
210
- expect(jsQrMocks.default).toHaveBeenCalledWith(expect.any(Uint8ClampedArray), 600, 400, {
211
- inversionAttempts: 'attemptBoth',
212
- });
213
- expect(mocks.scanFileV2).not.toHaveBeenCalled();
214
- });
215
-
216
- it('falls back to html5-qrcode for Safari uploaded files when jsQR does not decode a QR code', async () => {
217
- const handleScanSuccess = vi.fn();
218
- mocks.scanFileV2.mockResolvedValueOnce({ decodedText: 'BARCODE-CODE' });
219
- stubSafariBrowser();
220
- stubImageElement();
221
- stubCanvas();
222
-
223
- render(<FileScannerHost onScanSuccess={handleScanSuccess} />);
224
- await waitFor(() => expect(mocks.start).toHaveBeenCalled());
225
-
226
- fireEvent.click(screen.getByText('scan file'));
227
-
228
- await waitFor(() => expect(handleScanSuccess).toHaveBeenCalledWith('BARCODE-CODE'));
229
- expect(jsQrMocks.default).toHaveBeenCalled();
230
- expect(mocks.scanFileV2).toHaveBeenCalled();
231
- });
232
-
233
- it('tries multiple image scales before falling back from Safari jsQR scanning', async () => {
234
- const handleScanSuccess = vi.fn();
235
- Array.from({ length: 8 }).forEach((_, index) => {
236
- jsQrMocks.default.mockReturnValueOnce(index === 7 ? { data: 'SCALED-CODE' } : undefined);
237
- });
238
- stubSafariBrowser();
239
- stubImageElement({ width: 4000, height: 3000 });
240
- stubCanvas();
241
-
242
- render(<FileScannerHost onScanSuccess={handleScanSuccess} />);
243
- await waitFor(() => expect(mocks.start).toHaveBeenCalled());
244
-
245
- fireEvent.click(screen.getByText('scan file'));
246
-
247
- await waitFor(() => expect(handleScanSuccess).toHaveBeenCalledWith('SCALED-CODE'));
248
- expect(jsQrMocks.default).toHaveBeenCalledTimes(8);
249
- });
250
-
251
- it('tries enhanced grayscale QR images for blurry Safari uploads', async () => {
252
- const handleScanSuccess = vi.fn();
253
- jsQrMocks.default.mockReturnValueOnce(undefined).mockReturnValueOnce({ data: 'ENHANCED-CODE' });
254
- stubSafariBrowser();
255
- stubImageElement();
256
- stubCanvas();
257
-
258
- render(<FileScannerHost onScanSuccess={handleScanSuccess} />);
259
- await waitFor(() => expect(mocks.start).toHaveBeenCalled());
260
-
261
- fireEvent.click(screen.getByText('scan file'));
262
-
263
- await waitFor(() => expect(handleScanSuccess).toHaveBeenCalledWith('ENHANCED-CODE'));
264
- expect(jsQrMocks.default).toHaveBeenCalledTimes(2);
265
- });
266
123
  });
@@ -8,7 +8,6 @@
8
8
  */
9
9
 
10
10
  import { Html5Qrcode, Html5QrcodeScannerState, Html5QrcodeSupportedFormats } from 'html5-qrcode';
11
- import jsQR from 'jsqr';
12
11
  import { useCallback, useEffect, useState } from 'react';
13
12
  import type { CodeFormatsToSupport } from './types';
14
13
 
@@ -27,27 +26,6 @@ type UseCodeScannerOptions = {
27
26
  onScanFailure?: () => void;
28
27
  };
29
28
 
30
- type ImageDataVariant = {
31
- imageData: ImageData;
32
- maxSize: number;
33
- };
34
-
35
- type JsQRImageTransform = {
36
- contrast?: number;
37
- threshold?: number;
38
- };
39
-
40
- const QR_SCAN_IMAGE_SIZES = [3200, 2400, 1600, 1000];
41
- const QR_SCAN_IMAGE_TRANSFORMS: JsQRImageTransform[] = [
42
- {},
43
- { contrast: 3, threshold: 105 },
44
- { contrast: 2, threshold: 105 },
45
- { contrast: 3, threshold: 120 },
46
- { contrast: 2, threshold: 120 },
47
- { contrast: 3, threshold: 90 },
48
- { contrast: 4, threshold: 105 },
49
- ];
50
-
51
29
  export const DEFAULT_CODE_FORMATS: CodeFormatsToSupport = [
52
30
  Html5QrcodeSupportedFormats.QR_CODE,
53
31
  Html5QrcodeSupportedFormats.CODE_128,
@@ -84,126 +62,6 @@ async function stopScanner(scanner?: Html5Qrcode, options: { clear?: boolean } =
84
62
  }
85
63
  }
86
64
 
87
- function isSafariBrowser() {
88
- const { userAgent, vendor } = navigator;
89
- return /Apple/i.test(vendor) && /Safari/i.test(userAgent) && !/CriOS|FxiOS|EdgiOS|Chrome/i.test(userAgent);
90
- }
91
-
92
- function loadImage(file: File) {
93
- return new Promise<HTMLImageElement>((resolve, reject) => {
94
- const image = new Image();
95
- const url = URL.createObjectURL(file);
96
- const cleanup = () => URL.revokeObjectURL(url);
97
-
98
- image.onload = () => {
99
- cleanup();
100
- resolve(image);
101
- };
102
- image.onerror = (event) => {
103
- cleanup();
104
- reject(event);
105
- };
106
- image.onabort = (event) => {
107
- cleanup();
108
- reject(event);
109
- };
110
- image.src = url;
111
- });
112
- }
113
-
114
- function getScanImageSizes(naturalWidth: number, naturalHeight: number) {
115
- const maxSize = Math.max(naturalWidth, naturalHeight);
116
- const cappedMaxSize = Math.min(maxSize, QR_SCAN_IMAGE_SIZES[0]);
117
- return Array.from(new Set([cappedMaxSize, ...QR_SCAN_IMAGE_SIZES.filter((size) => size < cappedMaxSize)])).sort(
118
- (a, b) => b - a,
119
- );
120
- }
121
-
122
- function getImageDataFromImage(image: HTMLImageElement, maxSize: number): ImageDataVariant | undefined {
123
- const naturalWidth = image.naturalWidth || image.width;
124
- const naturalHeight = image.naturalHeight || image.height;
125
-
126
- const scale = Math.min(1, maxSize / Math.max(naturalWidth, naturalHeight));
127
- const width = Math.max(1, Math.round(naturalWidth * scale));
128
- const height = Math.max(1, Math.round(naturalHeight * scale));
129
- const canvas = document.createElement('canvas');
130
- canvas.width = width;
131
- canvas.height = height;
132
- const context = canvas.getContext('2d');
133
- if (!context) {
134
- return;
135
- }
136
-
137
- context.drawImage(image, 0, 0, width, height);
138
- return {
139
- imageData: context.getImageData(0, 0, width, height),
140
- maxSize,
141
- };
142
- }
143
-
144
- async function getImageDataVariants(file: File) {
145
- const image = await loadImage(file);
146
- const naturalWidth = image.naturalWidth || image.width;
147
- const naturalHeight = image.naturalHeight || image.height;
148
-
149
- if (!naturalWidth || !naturalHeight) {
150
- return;
151
- }
152
-
153
- return getScanImageSizes(naturalWidth, naturalHeight)
154
- .map((maxSize) => getImageDataFromImage(image, maxSize))
155
- .filter((variant): variant is ImageDataVariant => !!variant);
156
- }
157
-
158
- function getTransformedImageData(imageData: ImageData, transform: JsQRImageTransform) {
159
- const { contrast = 1, threshold } = transform;
160
- if (contrast === 1 && threshold == null) {
161
- return imageData.data;
162
- }
163
-
164
- const data = new Uint8ClampedArray(imageData.data);
165
- for (let index = 0; index < data.length; index += 4) {
166
- let luminance = data[index] * 0.299 + data[index + 1] * 0.587 + data[index + 2] * 0.114;
167
- luminance = Math.max(0, Math.min(255, (luminance - 128) * contrast + 128));
168
- if (threshold != null) {
169
- luminance = luminance < threshold ? 0 : 255;
170
- }
171
- data[index] = luminance;
172
- data[index + 1] = luminance;
173
- data[index + 2] = luminance;
174
- }
175
- return data;
176
- }
177
-
178
- async function scanFileWithJsQR(file: File, formatsToSupport?: CodeFormatsToSupport) {
179
- const formats = formatsToSupport?.length ? formatsToSupport : DEFAULT_CODE_FORMATS;
180
- if (!formats.includes(Html5QrcodeSupportedFormats.QR_CODE)) {
181
- throw new Error('QR_CODE is not included in the requested formats');
182
- }
183
-
184
- const variants = await getImageDataVariants(file);
185
- if (!variants?.length) {
186
- throw new Error('Failed to prepare uploaded image for QR decoding');
187
- }
188
-
189
- for (const { imageData } of variants) {
190
- for (const transform of QR_SCAN_IMAGE_TRANSFORMS) {
191
- const data = getTransformedImageData(imageData, transform);
192
- const qrCode = jsQR(data, imageData.width, imageData.height, { inversionAttempts: 'attemptBoth' });
193
- if (qrCode?.data) {
194
- return qrCode.data;
195
- }
196
- }
197
- }
198
-
199
- throw new Error('No QR code decoded by jsQR');
200
- }
201
-
202
- function shouldScanQrWithJsQR(formatsToSupport?: CodeFormatsToSupport) {
203
- const formats = formatsToSupport?.length ? formatsToSupport : DEFAULT_CODE_FORMATS;
204
- return formats.includes(Html5QrcodeSupportedFormats.QR_CODE);
205
- }
206
-
207
65
  export function useCodeScanner({
208
66
  enabled,
209
67
  elementId,
@@ -237,16 +95,6 @@ export function useCodeScanner({
237
95
 
238
96
  const startScanFile = useCallback(
239
97
  async (file: File) => {
240
- if (isSafariBrowser() && shouldScanQrWithJsQR(formatsToSupport)) {
241
- try {
242
- const decodedText = await scanFileWithJsQR(file, formatsToSupport);
243
- onScanSuccess(decodedText);
244
- return;
245
- } catch {
246
- // Fall through to html5-qrcode so barcode uploads still work in Safari.
247
- }
248
- }
249
-
250
98
  if (!scanner) {
251
99
  return;
252
100
  }
@@ -255,12 +103,12 @@ export function useCodeScanner({
255
103
  try {
256
104
  const result = await scanner.scanFileV2(file, false);
257
105
  onScanSuccess(result.decodedText);
258
- } catch {
106
+ } catch (error) {
259
107
  onScanFailure?.();
260
108
  await startScanCamera(scanner);
261
109
  }
262
110
  },
263
- [formatsToSupport, onScanFailure, onScanSuccess, scanner, startScanCamera],
111
+ [onScanFailure, onScanSuccess, scanner, startScanCamera],
264
112
  );
265
113
 
266
114
  useEffect(() => {