@gentleduck/upload 0.2.6 → 0.3.0
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/README.md +170 -193
- package/dist/core/index.d.ts +2 -3
- package/dist/core/index.js +1 -1
- package/dist/core-BVgloTiP.js +10 -0
- package/dist/index-DCqyBVcx.d.ts +1441 -0
- package/dist/index-DsZHPmJm.d.ts +225 -0
- package/dist/index-KROXtLQh.d.ts +281 -0
- package/dist/index.d.ts +4 -5
- package/dist/index.js +1 -1
- package/dist/react/index.d.ts +2 -51
- package/dist/react/index.js +1 -1
- package/dist/react-euPrDv24.js +1 -0
- package/dist/strategies/index.d.ts +2 -56
- package/dist/strategies/index.js +1 -1
- package/dist/strategies-CUF0s68j.js +1 -0
- package/package.json +3 -2
- package/src/{__test__ → __tests__}/emitter.test.ts +4 -4
- package/src/{__test__ → __tests__}/fingerprint.test.ts +1 -1
- package/src/{__test__ → __tests__}/guards.test.ts +15 -5
- package/src/__tests__/id.test.ts +67 -0
- package/src/__tests__/mime-sniff.test.ts +142 -0
- package/src/{__test__ → __tests__}/persistence.test.ts +43 -8
- package/src/__tests__/sanitize-filename.test.ts +109 -0
- package/src/__tests__/sleep.test.ts +34 -0
- package/src/__tests__/static-analysis.test.ts +145 -0
- package/src/{__test__ → __tests__}/store-libs.test.ts +57 -3
- package/src/__tests__/transport-fetch.test.ts +108 -0
- package/src/{__test__ → __tests__}/transport.test.ts +5 -5
- package/src/core/__tests__/errors.test.ts +152 -0
- package/src/core/client.ts +3 -0
- package/src/core/contracts/contracts.types.ts +335 -141
- package/src/core/contracts/index.ts +3 -8
- package/src/core/contracts/transport/index.ts +3 -2
- package/src/core/contracts/transport/transport.fetch.ts +203 -0
- package/src/core/contracts/transport/transport.libs.ts +7 -8
- package/src/core/contracts/transport/transport.ts +91 -83
- package/src/core/contracts/transport/transport.types.ts +71 -35
- package/src/core/engine/__tests__/config-nan-bypass.test.ts +40 -0
- package/src/core/engine/engine.libs.ts +35 -0
- package/src/core/engine/engine.types.ts +367 -241
- package/src/core/engine/index.ts +3 -8
- package/src/core/engine/reducer.ts +89 -43
- package/src/core/engine/store/handlers/add-file.ts +39 -29
- package/src/core/engine/store/handlers/cancel.ts +13 -8
- package/src/core/engine/store/handlers/clean-up.ts +11 -8
- package/src/core/engine/store/handlers/create-intent.ts +82 -27
- package/src/core/engine/store/handlers/finalize.ts +30 -13
- package/src/core/engine/store/handlers/index.ts +7 -7
- package/src/core/engine/store/handlers/pause.ts +8 -6
- package/src/core/engine/store/handlers/run-upload.ts +49 -34
- package/src/core/engine/store/index.ts +27 -21
- package/src/core/engine/store/store.dispatch.ts +10 -8
- package/src/core/engine/store/store.libs.ts +184 -41
- package/src/core/engine/store/store.runtime.ts +45 -36
- package/src/core/engine/store/store.schedule.ts +27 -21
- package/src/core/engine/store/store.types.ts +177 -227
- package/src/core/engine/validation/__tests__/intent.test.ts +70 -0
- package/src/core/engine/validation/file.ts +7 -3
- package/src/core/engine/validation/index.ts +1 -0
- package/src/core/engine/validation/intent.ts +49 -27
- package/src/core/engine/validation/list.ts +14 -15
- package/src/core/engine/validation/mime.ts +39 -0
- package/src/core/errors.ts +271 -0
- package/src/core/index.ts +28 -5
- package/src/core/persistence/__tests__/deserialize-nan-bypass.test.ts +100 -0
- package/src/core/persistence/__tests__/persistence.test.ts +85 -0
- package/src/core/persistence/adapters.indexeddb.ts +9 -3
- package/src/core/persistence/adapters.local.ts +9 -3
- package/src/core/persistence/adapters.memory.ts +35 -13
- package/src/core/persistence/index.ts +5 -5
- package/src/core/persistence/persistence.ts +73 -60
- package/src/core/persistence/persistence.types.ts +140 -60
- package/src/core/utils/__tests__/guards.test.ts +93 -0
- package/src/core/utils/__tests__/url-safety.test.ts +112 -0
- package/src/core/utils/async.ts +7 -2
- package/src/core/utils/constants.ts +3 -9
- package/src/core/utils/emitter/emitter.ts +7 -24
- package/src/core/utils/emitter/emitter.types.ts +33 -16
- package/src/core/utils/emitter/index.ts +2 -74
- package/src/core/utils/fingerprint.ts +16 -5
- package/src/core/utils/guards.ts +91 -1
- package/src/core/utils/hash/__tests__/sha256.test.ts +61 -0
- package/src/core/utils/hash/index.ts +126 -0
- package/src/core/utils/hash/sha256.ts +179 -0
- package/src/core/utils/id.ts +33 -1
- package/src/core/utils/index.ts +15 -6
- package/src/core/utils/mime-sniff.ts +119 -0
- package/src/core/utils/sanitize-filename.ts +85 -0
- package/src/core/utils/url-safety.ts +156 -0
- package/src/react/__tests__/use-dropzone.test.ts +64 -0
- package/src/react/index.ts +5 -2
- package/src/react/upload-provider.tsx +68 -53
- package/src/react/uploader.types.ts +58 -61
- package/src/react/use-dropzone.tsx +226 -0
- package/src/react/use-uploader.tsx +98 -91
- package/src/strategies/_shared/__tests__/retry.test.ts +120 -0
- package/src/strategies/_shared/retry.ts +79 -0
- package/src/strategies/index.ts +9 -15
- package/src/strategies/multipart/__tests__/multipart.test.ts +229 -0
- package/src/strategies/multipart/index.ts +206 -108
- package/src/strategies/post/__tests__/post-url-safety.test.ts +119 -0
- package/src/strategies/post/index.ts +115 -33
- package/src/strategies/put/__tests__/put.test.ts +132 -0
- package/src/strategies/put/index.ts +139 -0
- package/src/strategies/registry/index.ts +12 -9
- package/src/strategies/tus/__tests__/tus.test.ts +159 -0
- package/src/strategies/tus/index.ts +211 -0
- package/dist/async-DxRWu4le.js +0 -1
- package/dist/core-CMkrP9DX.js +0 -2
- package/dist/index-C38Jmizu.d.ts +0 -1005
- package/dist/strategy.types-gg9ZStRe.d.ts +0 -225
- package/src/__test__/effect-pool.test.ts +0 -5
- package/src/__test__/id.test.ts +0 -14
- package/src/__test__/store-integration.test.ts +0 -5
- package/src/core/client/client.ts +0 -50
- package/src/core/client/client.types.ts +0 -184
- package/src/core/client/config.types.ts +0 -77
- package/src/core/client/index.ts +0 -4
- package/src/core/client/plugins.types.ts +0 -46
- package/src/core/client/retry.types.ts +0 -6
- package/src/core/client/uploadClient.ts +0 -19
- package/src/core/contracts/backendApi.ts +0 -62
- package/src/core/contracts/cursor.types.ts +0 -23
- package/src/core/contracts/errors.types.ts +0 -67
- package/src/core/contracts/fingerprint.types.ts +0 -18
- package/src/core/contracts/intent.types.ts +0 -39
- package/src/core/contracts/result.types.ts +0 -11
- package/src/core/contracts/strategy.types.ts +0 -45
- package/src/core/engine/commands.types.ts +0 -17
- package/src/core/engine/event-map.types.ts +0 -45
- package/src/core/engine/internal-events.types.ts +0 -202
- package/src/core/engine/outcome.types.ts +0 -9
- package/src/core/engine/progress.types.ts +0 -15
- package/src/core/engine/store/handlers.ts +0 -819
- package/src/core/engine/validation.ts +0 -216
- /package/src/{__test__ → __tests__}/transport-libs.test.ts +0 -0
|
@@ -1,70 +1,85 @@
|
|
|
1
|
-
|
|
1
|
+
import { createContext, useContext, useEffect } from 'react'
|
|
2
|
+
import type { Contracts } from '../core'
|
|
3
|
+
import type { Store } from '../core/engine/store'
|
|
4
|
+
import type { Uploader } from './uploader.types'
|
|
2
5
|
|
|
3
|
-
|
|
4
|
-
import { type CursorMap, type IntentMap, isRecord, type UploadResultBase } from '../core'
|
|
5
|
-
import type { UploadStore } from '../core/engine/store'
|
|
6
|
+
const UploadContext = createContext<Store.UploadStore<any, any, any, any> | null>(null)
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
/**
|
|
9
|
+
* Context provider that distributes the upload store across the React component hierarchy.
|
|
10
|
+
*
|
|
11
|
+
* Wrap this provider around the top of your layout to enable child hooks like `useUploader`
|
|
12
|
+
* to access upload status.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```tsx
|
|
16
|
+
* const store = createUploadStore({ ... });
|
|
17
|
+
*
|
|
18
|
+
* function App() {
|
|
19
|
+
* return (
|
|
20
|
+
* <UploadProvider store={store}>
|
|
21
|
+
* <UploadForm />
|
|
22
|
+
* </UploadProvider>
|
|
23
|
+
* );
|
|
24
|
+
* }
|
|
25
|
+
* ```
|
|
26
|
+
* @author wildduck2 <https://github.com/wildduck2>
|
|
27
|
+
*/
|
|
12
28
|
export function UploadProvider<
|
|
13
|
-
M extends
|
|
14
|
-
C extends
|
|
29
|
+
M extends Contracts.Intent.Map,
|
|
30
|
+
C extends Contracts.Cursor.Map<M>,
|
|
15
31
|
P extends string,
|
|
16
|
-
R extends
|
|
17
|
-
>({ store, children }:
|
|
32
|
+
R extends Contracts.Result.Base = Contracts.Result.Base,
|
|
33
|
+
>({ store, children }: Uploader.ProviderProps<M, C, P, R>) {
|
|
34
|
+
// Safe cleanup: destroy the store runner instance if the provider is unmounted.
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
return () => {
|
|
37
|
+
if ('destroy' in store && typeof store.destroy === 'function') {
|
|
38
|
+
;(store as unknown as { destroy: () => void }).destroy()
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}, [store])
|
|
42
|
+
|
|
18
43
|
return <UploadContext.Provider value={store}>{children}</UploadContext.Provider>
|
|
19
44
|
}
|
|
20
45
|
|
|
21
46
|
/**
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
47
|
+
* Returns the active upload store instance from context.
|
|
48
|
+
* Throws a runtime exception if called outside an `<UploadProvider>`.
|
|
49
|
+
* @author wildduck2 <https://github.com/wildduck2>
|
|
25
50
|
*/
|
|
26
|
-
export function useOptionalUploadStore<
|
|
27
|
-
M extends IntentMap,
|
|
28
|
-
C extends CursorMap<M>,
|
|
29
|
-
P extends string,
|
|
30
|
-
R extends UploadResultBase = UploadResultBase,
|
|
31
|
-
>(): UploadStore<M, C, P, R> | null {
|
|
32
|
-
const store = useContext(UploadContext)
|
|
33
|
-
if (store === null) {
|
|
34
|
-
return null
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
if (!isUploadStore<M, C, P, R>(store)) {
|
|
38
|
-
throw new Error('UploadProvider received an invalid store value')
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
return store
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/** Read the upload store from context. Throws when called outside `UploadProvider`. */
|
|
45
51
|
export function useUploadStore<
|
|
46
|
-
M extends
|
|
47
|
-
C extends
|
|
52
|
+
M extends Contracts.Intent.Map,
|
|
53
|
+
C extends Contracts.Cursor.Map<M>,
|
|
48
54
|
P extends string,
|
|
49
|
-
R extends
|
|
50
|
-
>(): UploadStore<M, C, P, R> {
|
|
51
|
-
const
|
|
52
|
-
if (!
|
|
53
|
-
throw new Error('useUploadStore must be used within UploadProvider')
|
|
55
|
+
R extends Contracts.Result.Base = Contracts.Result.Base,
|
|
56
|
+
>(): Store.UploadStore<M, C, P, R> {
|
|
57
|
+
const context = useContext(UploadContext)
|
|
58
|
+
if (!context) {
|
|
59
|
+
throw new Error('useUploadStore must be used within an <UploadProvider>')
|
|
54
60
|
}
|
|
55
|
-
return
|
|
61
|
+
return context as Store.UploadStore<M, C, P, R>
|
|
56
62
|
}
|
|
57
63
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
64
|
+
/**
|
|
65
|
+
* Runtime type guard: returns `true` when `value` exposes the upload-store
|
|
66
|
+
* surface (`getSnapshot`, `subscribe`, `dispatch`, `on`, `off`, `waitFor`).
|
|
67
|
+
* @author wildduck2 <https://github.com/wildduck2>
|
|
68
|
+
*/
|
|
69
|
+
export function isUploadStore<
|
|
70
|
+
M extends Contracts.Intent.Map,
|
|
71
|
+
C extends Contracts.Cursor.Map<M>,
|
|
72
|
+
P extends string,
|
|
73
|
+
R extends Contracts.Result.Base = Contracts.Result.Base,
|
|
74
|
+
>(value: unknown): value is Store.UploadStore<M, C, P, R> {
|
|
75
|
+
if (typeof value !== 'object' || value === null) return false
|
|
76
|
+
const val = value as Record<string, unknown>
|
|
62
77
|
return (
|
|
63
|
-
typeof
|
|
64
|
-
typeof
|
|
65
|
-
typeof
|
|
66
|
-
typeof
|
|
67
|
-
typeof
|
|
68
|
-
typeof
|
|
78
|
+
typeof val['getSnapshot'] === 'function' &&
|
|
79
|
+
typeof val['subscribe'] === 'function' &&
|
|
80
|
+
typeof val['dispatch'] === 'function' &&
|
|
81
|
+
typeof val['on'] === 'function' &&
|
|
82
|
+
typeof val['off'] === 'function' &&
|
|
83
|
+
typeof val['waitFor'] === 'function'
|
|
69
84
|
)
|
|
70
85
|
}
|
|
@@ -1,83 +1,80 @@
|
|
|
1
|
+
import type React from 'react'
|
|
1
2
|
import type { Contracts, Engine } from '../core'
|
|
2
3
|
import type { Store } from '../core/engine/store'
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
|
-
* React
|
|
6
|
-
*
|
|
7
|
-
* Houses the public typings consumed by the React hooks so generated
|
|
8
|
-
* `.d.ts` files do not leak internal generic names.
|
|
9
|
-
*
|
|
6
|
+
* Public typings and namespaces consumed by React hook components.
|
|
10
7
|
* @author wildduck2 <https://github.com/wildduck2>
|
|
11
8
|
*/
|
|
12
9
|
export namespace Uploader {
|
|
13
10
|
/**
|
|
14
|
-
*
|
|
11
|
+
* Return state payload returned by the `useUploader` hook.
|
|
15
12
|
*
|
|
16
|
-
* @template
|
|
17
|
-
* @
|
|
18
|
-
* @
|
|
13
|
+
* @template M - Intent map type
|
|
14
|
+
* @template C - Cursor map type
|
|
15
|
+
* @template P - Purpose string union type
|
|
16
|
+
* @template R - Backend result shape
|
|
19
17
|
*/
|
|
20
|
-
export type
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
18
|
+
export type State<
|
|
19
|
+
M extends Contracts.Intent.Map,
|
|
20
|
+
C extends Contracts.Cursor.Map<M>,
|
|
21
|
+
P extends string,
|
|
22
|
+
R extends Contracts.Result.Base = Contracts.Result.Base,
|
|
23
|
+
> = Pick<Store.UploadStore<M, C, P, R>, 'dispatch' | 'on' | 'off'> & {
|
|
24
|
+
/** List of all registered upload items currently tracked. */
|
|
25
|
+
items: Array<Engine.Item<M, C, P, R>>
|
|
26
|
+
|
|
27
|
+
/** Upload items grouped by phase. */
|
|
28
|
+
byPhase: Record<Engine.Phase, Array<Engine.Item<M, C, P, R>>>
|
|
29
|
+
|
|
30
|
+
/** Subset of items currently active in the transfer phase. */
|
|
31
|
+
uploading: Array<Engine.Item<M, C, P, R>>
|
|
32
|
+
|
|
33
|
+
/** Subset of items currently paused. */
|
|
34
|
+
paused: Array<Engine.Item<M, C, P, R>>
|
|
35
|
+
|
|
36
|
+
/** Subset of items that successfully completed uploading and backend finalization. */
|
|
37
|
+
completed: Array<Engine.Item<M, C, P, R>>
|
|
38
|
+
|
|
39
|
+
/** Subset of items currently in the error state. */
|
|
40
|
+
failed: Array<Engine.Item<M, C, P, R>>
|
|
41
|
+
|
|
42
|
+
/** Subset of items validated and waiting to begin uploading. */
|
|
43
|
+
ready: Array<Engine.Item<M, C, P, R>>
|
|
44
|
+
}
|
|
24
45
|
|
|
25
46
|
/**
|
|
26
|
-
*
|
|
47
|
+
* Bound action callbacks returned by context hooks.
|
|
27
48
|
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* @template
|
|
31
|
-
* @template
|
|
32
|
-
* @
|
|
49
|
+
* Houses both the bound dispatchers and the raw underlying store reference.
|
|
50
|
+
*
|
|
51
|
+
* @template M - Intent map type
|
|
52
|
+
* @template C - Cursor map type
|
|
53
|
+
* @template P - Purpose string union type
|
|
54
|
+
* @template R - Backend result shape
|
|
33
55
|
*/
|
|
34
|
-
export
|
|
35
|
-
M extends Contracts.
|
|
36
|
-
C extends Contracts.
|
|
56
|
+
export type IActions<
|
|
57
|
+
M extends Contracts.Intent.Map,
|
|
58
|
+
C extends Contracts.Cursor.Map<M>,
|
|
37
59
|
P extends string,
|
|
38
|
-
R extends Contracts.
|
|
39
|
-
> {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
* Items grouped by phase. `Partial<Record<Engine.Phase, ...>>` so consumers
|
|
43
|
-
* get autocomplete for the eleven known phases and a missing group reads
|
|
44
|
-
* as `undefined`.
|
|
45
|
-
*/
|
|
46
|
-
byPhase: Partial<Record<Engine.Phase, Engine.Item<M, C, P, R>[]>>
|
|
47
|
-
dispatch: (cmd: Engine.Command<P>) => void
|
|
48
|
-
on: <K extends keyof Engine.EventMap<M, C, P, R> & string>(
|
|
49
|
-
type: K,
|
|
50
|
-
cb: (payload: Engine.EventMap<M, C, P, R>[K]) => void,
|
|
51
|
-
) => () => void
|
|
52
|
-
off: <K extends keyof Engine.EventMap<M, C, P, R> & string>(
|
|
53
|
-
type: K,
|
|
54
|
-
cb: (payload: Engine.EventMap<M, C, P, R>[K]) => void,
|
|
55
|
-
) => void
|
|
56
|
-
uploading: Engine.Item<M, C, P, R>[]
|
|
57
|
-
paused: Engine.Item<M, C, P, R>[]
|
|
58
|
-
completed: Engine.Item<M, C, P, R>[]
|
|
59
|
-
failed: Engine.Item<M, C, P, R>[]
|
|
60
|
-
ready: Engine.Item<M, C, P, R>[]
|
|
60
|
+
R extends Contracts.Result.Base = Contracts.Result.Base,
|
|
61
|
+
> = Pick<Store.UploadStore<M, C, P, R>, 'dispatch' | 'on'> & {
|
|
62
|
+
/** The raw upload store instance. */
|
|
63
|
+
store: Store.UploadStore<M, C, P, R>
|
|
61
64
|
}
|
|
62
65
|
|
|
63
66
|
/**
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
* Declared explicitly so generated `.d.ts` files do not leak internal
|
|
67
|
-
* generic names.
|
|
68
|
-
*
|
|
69
|
-
* @template M Intent map.
|
|
70
|
-
* @template C Cursor map.
|
|
71
|
-
* @template P Purpose union.
|
|
72
|
-
* @template R Backend result shape; defaults to `Contracts.IResultBase`.
|
|
73
|
-
* @author wildduck2 <https://github.com/wildduck2>
|
|
67
|
+
* Props for the `<UploadProvider>` component.
|
|
74
68
|
*/
|
|
75
|
-
export
|
|
76
|
-
M extends Contracts.
|
|
77
|
-
C extends Contracts.
|
|
69
|
+
export interface ProviderProps<
|
|
70
|
+
M extends Contracts.Intent.Map,
|
|
71
|
+
C extends Contracts.Cursor.Map<M>,
|
|
78
72
|
P extends string,
|
|
79
|
-
R extends Contracts.
|
|
80
|
-
>
|
|
81
|
-
store
|
|
73
|
+
R extends Contracts.Result.Base = Contracts.Result.Base,
|
|
74
|
+
> {
|
|
75
|
+
/** The upload store instance to distribute. */
|
|
76
|
+
store: Store.UploadStore<M, C, P, R>
|
|
77
|
+
/** React child elements. */
|
|
78
|
+
children: React.ReactNode
|
|
82
79
|
}
|
|
83
80
|
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import type * as React from 'react'
|
|
2
|
+
import { useCallback, useMemo, useRef, useState } from 'react'
|
|
3
|
+
import type { Contracts } from '../core'
|
|
4
|
+
import type { Store } from '../core/engine/store'
|
|
5
|
+
import { useUploadStore } from './upload-provider'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Test a file against one token from an `accept` string. A token is a MIME type
|
|
9
|
+
* (`image/png`), a MIME wildcard (`image/*`), or a filename extension (`.pdf`).
|
|
10
|
+
* @internal exported for tests.
|
|
11
|
+
*/
|
|
12
|
+
export function matchesAcceptToken(file: File, token: string): boolean {
|
|
13
|
+
const t = token.trim().toLowerCase()
|
|
14
|
+
if (!t) return false
|
|
15
|
+
|
|
16
|
+
if (t.startsWith('.')) {
|
|
17
|
+
return file.name.toLowerCase().endsWith(t)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const type = (file.type || '').toLowerCase()
|
|
21
|
+
if (t.endsWith('/*')) {
|
|
22
|
+
return type.startsWith(`${t.slice(0, -1)}`) // "image/" prefix
|
|
23
|
+
}
|
|
24
|
+
return type === t
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* True when `file` satisfies an `accept` list (comma-separated tokens). An empty
|
|
29
|
+
* or absent list accepts everything.
|
|
30
|
+
* @internal exported for tests.
|
|
31
|
+
*/
|
|
32
|
+
export function fileMatchesAccept(file: File, accept?: string): boolean {
|
|
33
|
+
if (!accept) return true
|
|
34
|
+
const tokens = accept
|
|
35
|
+
.split(',')
|
|
36
|
+
.map((s) => s.trim())
|
|
37
|
+
.filter(Boolean)
|
|
38
|
+
if (tokens.length === 0) return true
|
|
39
|
+
return tokens.some((tok) => matchesAcceptToken(file, tok))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Filter a dropped/selected file list by `accept` and collapse to a single file
|
|
44
|
+
* when `multiple` is false.
|
|
45
|
+
* @internal exported for tests.
|
|
46
|
+
*/
|
|
47
|
+
export function selectFiles(
|
|
48
|
+
files: File[],
|
|
49
|
+
opts: { accept?: string | undefined; multiple?: boolean | undefined },
|
|
50
|
+
): File[] {
|
|
51
|
+
const accepted = files.filter((f) => fileMatchesAccept(f, opts.accept))
|
|
52
|
+
return opts.multiple === false ? accepted.slice(0, 1) : accepted
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Options for {@link useDropzone}. */
|
|
56
|
+
export type UseDropzoneOptions<P extends string> = {
|
|
57
|
+
/** Purpose forwarded to the `addFiles` command. */
|
|
58
|
+
purpose: P
|
|
59
|
+
/** Optional caller metadata forwarded to every backend API call. */
|
|
60
|
+
meta?: Record<string, unknown>
|
|
61
|
+
/**
|
|
62
|
+
* Comma-separated `accept` list (`"image/*,.pdf"`). Filters dropped files and
|
|
63
|
+
* populates the input's `accept` attribute. Absent = accept everything.
|
|
64
|
+
*/
|
|
65
|
+
accept?: string
|
|
66
|
+
/** Allow multiple files. Defaults to `true`. */
|
|
67
|
+
multiple?: boolean
|
|
68
|
+
/** Disable all interaction (no drop, no click-to-open). Defaults to `false`. */
|
|
69
|
+
disabled?: boolean
|
|
70
|
+
/** Do not open the file dialog when the root is clicked. Defaults to `false`. */
|
|
71
|
+
noClick?: boolean
|
|
72
|
+
/**
|
|
73
|
+
* Override the default dispatch. When provided, accepted files are handed here
|
|
74
|
+
* instead of being dispatched as `addFiles` to the store.
|
|
75
|
+
*/
|
|
76
|
+
onFiles?: (files: File[]) => void
|
|
77
|
+
/** Store instance to dispatch to. Falls back to the `<UploadProvider>` store. */
|
|
78
|
+
store?: Store.UploadStore<Contracts.Intent.Map, Contracts.Cursor.Map<Contracts.Intent.Map>, P, Contracts.Result.Base>
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Return value of {@link useDropzone}. */
|
|
82
|
+
export type UseDropzoneReturn = {
|
|
83
|
+
/** True while files are being dragged over the root. */
|
|
84
|
+
isDragging: boolean
|
|
85
|
+
/** Programmatically open the native file picker. */
|
|
86
|
+
open: () => void
|
|
87
|
+
/** Ref to attach to the hidden `<input type="file">`. */
|
|
88
|
+
inputRef: React.RefObject<HTMLInputElement | null>
|
|
89
|
+
/** Spread onto the drop-target element. */
|
|
90
|
+
getRootProps: (extra?: React.HTMLAttributes<HTMLElement>) => React.HTMLAttributes<HTMLElement>
|
|
91
|
+
/** Spread onto the hidden `<input type="file">`. */
|
|
92
|
+
getInputProps: (
|
|
93
|
+
extra?: React.InputHTMLAttributes<HTMLInputElement>,
|
|
94
|
+
) => React.InputHTMLAttributes<HTMLInputElement> & { ref: React.Ref<HTMLInputElement> }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Headless drag-and-drop / file-picker hook.
|
|
99
|
+
*
|
|
100
|
+
* Wires drop and file-input events to the upload store's `addFiles` command
|
|
101
|
+
* (or a custom `onFiles` handler). Returns prop-getters for the drop target and
|
|
102
|
+
* a hidden file input plus a live `isDragging` flag — styling and markup are
|
|
103
|
+
* entirely yours.
|
|
104
|
+
*
|
|
105
|
+
* @example
|
|
106
|
+
* ```tsx
|
|
107
|
+
* function Drop() {
|
|
108
|
+
* const { getRootProps, getInputProps, isDragging, open } = useDropzone({
|
|
109
|
+
* purpose: 'attachment',
|
|
110
|
+
* accept: 'image/*',
|
|
111
|
+
* });
|
|
112
|
+
* return (
|
|
113
|
+
* <div {...getRootProps()} data-active={isDragging}>
|
|
114
|
+
* <input {...getInputProps()} />
|
|
115
|
+
* <button type="button" onClick={open}>Choose files</button>
|
|
116
|
+
* </div>
|
|
117
|
+
* );
|
|
118
|
+
* }
|
|
119
|
+
* ```
|
|
120
|
+
* @author wildduck2 <https://github.com/wildduck2>
|
|
121
|
+
*/
|
|
122
|
+
export function useDropzone<P extends string>(options: UseDropzoneOptions<P>): UseDropzoneReturn {
|
|
123
|
+
const { purpose, meta, accept, multiple = true, disabled = false, noClick = false, onFiles } = options
|
|
124
|
+
|
|
125
|
+
// Read the context store unconditionally (hooks rule); prefer an explicit one.
|
|
126
|
+
const contextStore = useUploadStore<
|
|
127
|
+
Contracts.Intent.Map,
|
|
128
|
+
Contracts.Cursor.Map<Contracts.Intent.Map>,
|
|
129
|
+
P,
|
|
130
|
+
Contracts.Result.Base
|
|
131
|
+
>()
|
|
132
|
+
const store = options.store ?? contextStore
|
|
133
|
+
|
|
134
|
+
const inputRef = useRef<HTMLInputElement | null>(null)
|
|
135
|
+
const [isDragging, setIsDragging] = useState(false)
|
|
136
|
+
// Depth counter so nested dragenter/dragleave don't flicker the flag.
|
|
137
|
+
const dragDepth = useRef(0)
|
|
138
|
+
|
|
139
|
+
const handleFiles = useCallback(
|
|
140
|
+
(list: FileList | null) => {
|
|
141
|
+
if (!list || list.length === 0) return
|
|
142
|
+
const chosen = selectFiles(Array.from(list), { accept, multiple })
|
|
143
|
+
if (chosen.length === 0) return
|
|
144
|
+
if (onFiles) {
|
|
145
|
+
onFiles(chosen)
|
|
146
|
+
} else {
|
|
147
|
+
store.dispatch({ type: 'addFiles', files: chosen, purpose, meta })
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
[accept, multiple, onFiles, store, purpose, meta],
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
const open = useCallback(() => {
|
|
154
|
+
if (disabled) return
|
|
155
|
+
inputRef.current?.click()
|
|
156
|
+
}, [disabled])
|
|
157
|
+
|
|
158
|
+
const getRootProps = useCallback<UseDropzoneReturn['getRootProps']>(
|
|
159
|
+
(extra = {}) => ({
|
|
160
|
+
...extra,
|
|
161
|
+
onDragEnter: (e) => {
|
|
162
|
+
extra.onDragEnter?.(e)
|
|
163
|
+
if (disabled) return
|
|
164
|
+
e.preventDefault()
|
|
165
|
+
e.stopPropagation()
|
|
166
|
+
dragDepth.current += 1
|
|
167
|
+
setIsDragging(true)
|
|
168
|
+
},
|
|
169
|
+
onDragOver: (e) => {
|
|
170
|
+
extra.onDragOver?.(e)
|
|
171
|
+
if (disabled) return
|
|
172
|
+
// Required so the browser fires `drop` instead of navigating.
|
|
173
|
+
e.preventDefault()
|
|
174
|
+
e.stopPropagation()
|
|
175
|
+
},
|
|
176
|
+
onDragLeave: (e) => {
|
|
177
|
+
extra.onDragLeave?.(e)
|
|
178
|
+
if (disabled) return
|
|
179
|
+
e.preventDefault()
|
|
180
|
+
e.stopPropagation()
|
|
181
|
+
dragDepth.current = Math.max(0, dragDepth.current - 1)
|
|
182
|
+
if (dragDepth.current === 0) setIsDragging(false)
|
|
183
|
+
},
|
|
184
|
+
onDrop: (e) => {
|
|
185
|
+
extra.onDrop?.(e)
|
|
186
|
+
if (disabled) return
|
|
187
|
+
e.preventDefault()
|
|
188
|
+
e.stopPropagation()
|
|
189
|
+
dragDepth.current = 0
|
|
190
|
+
setIsDragging(false)
|
|
191
|
+
handleFiles(e.dataTransfer?.files ?? null)
|
|
192
|
+
},
|
|
193
|
+
onClick: (e) => {
|
|
194
|
+
extra.onClick?.(e)
|
|
195
|
+
if (disabled || noClick) return
|
|
196
|
+
open()
|
|
197
|
+
},
|
|
198
|
+
}),
|
|
199
|
+
[disabled, noClick, open, handleFiles],
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
const getInputProps = useCallback<UseDropzoneReturn['getInputProps']>(
|
|
203
|
+
(extra = {}) => ({
|
|
204
|
+
...extra,
|
|
205
|
+
ref: inputRef,
|
|
206
|
+
type: 'file',
|
|
207
|
+
multiple,
|
|
208
|
+
disabled,
|
|
209
|
+
...(accept ? { accept } : {}),
|
|
210
|
+
// Visually hidden; caller styles can extend but not un-hide by accident.
|
|
211
|
+
style: { display: 'none', ...extra.style },
|
|
212
|
+
onChange: (e) => {
|
|
213
|
+
extra.onChange?.(e)
|
|
214
|
+
handleFiles(e.target.files)
|
|
215
|
+
// Reset so selecting the same file again re-fires change.
|
|
216
|
+
e.target.value = ''
|
|
217
|
+
},
|
|
218
|
+
}),
|
|
219
|
+
[multiple, disabled, accept, handleFiles],
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
return useMemo(
|
|
223
|
+
() => ({ isDragging, open, inputRef, getRootProps, getInputProps }),
|
|
224
|
+
[isDragging, open, getRootProps, getInputProps],
|
|
225
|
+
)
|
|
226
|
+
}
|