@djangocfg/ui-tools 2.1.200 → 2.1.202
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/package.json +6 -6
- package/src/tools/Uploader/README.md +89 -6
- package/src/tools/Uploader/Uploader.story.tsx +116 -1
- package/src/tools/Uploader/components/UploadDropzone.tsx +34 -1
- package/src/tools/Uploader/components/UploadPreviewItem.tsx +10 -5
- package/src/tools/Uploader/components/Uploader.tsx +6 -0
- package/src/tools/Uploader/hooks/index.ts +2 -0
- package/src/tools/Uploader/hooks/useClipboardPaste.ts +205 -0
- package/src/tools/Uploader/index.ts +3 -0
- package/src/tools/Uploader/types/index.ts +15 -0
- package/src/tools/Uploader/utils/formatters.ts +28 -0
- package/src/tools/Uploader/utils/index.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/ui-tools",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.202",
|
|
4
4
|
"description": "Heavy React tools with lazy loading - for Electron, Vite, CRA, Next.js apps",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ui-tools",
|
|
@@ -78,8 +78,8 @@
|
|
|
78
78
|
"check": "tsc --noEmit"
|
|
79
79
|
},
|
|
80
80
|
"peerDependencies": {
|
|
81
|
-
"@djangocfg/i18n": "^2.1.
|
|
82
|
-
"@djangocfg/ui-core": "^2.1.
|
|
81
|
+
"@djangocfg/i18n": "^2.1.202",
|
|
82
|
+
"@djangocfg/ui-core": "^2.1.202",
|
|
83
83
|
"lucide-react": "^0.545.0",
|
|
84
84
|
"react": "^19.0.0",
|
|
85
85
|
"react-dom": "^19.0.0",
|
|
@@ -112,10 +112,10 @@
|
|
|
112
112
|
"@maplibre/maplibre-gl-geocoder": "^1.7.0"
|
|
113
113
|
},
|
|
114
114
|
"devDependencies": {
|
|
115
|
-
"@djangocfg/i18n": "^2.1.
|
|
115
|
+
"@djangocfg/i18n": "^2.1.202",
|
|
116
116
|
"@djangocfg/playground": "workspace:*",
|
|
117
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
118
|
-
"@djangocfg/ui-core": "^2.1.
|
|
117
|
+
"@djangocfg/typescript-config": "^2.1.202",
|
|
118
|
+
"@djangocfg/ui-core": "^2.1.202",
|
|
119
119
|
"@types/mapbox__mapbox-gl-draw": "^1.4.8",
|
|
120
120
|
"@types/node": "^24.7.2",
|
|
121
121
|
"@types/react": "^19.1.0",
|
|
@@ -49,7 +49,32 @@ All-in-one component with dropzone and preview list.
|
|
|
49
49
|
/>
|
|
50
50
|
```
|
|
51
51
|
|
|
52
|
-
###
|
|
52
|
+
### Standalone — custom upload handler (no UploadProvider)
|
|
53
|
+
|
|
54
|
+
If you handle uploads yourself (custom API hooks, multipart POST, etc.), pass `uploadFn` instead of wrapping with `UploadProvider`. Supports drag/drop, click, and Ctrl+V paste.
|
|
55
|
+
|
|
56
|
+
```tsx
|
|
57
|
+
import { UploadDropzone } from '@djangocfg/ui-tools/upload';
|
|
58
|
+
|
|
59
|
+
function MyUploader() {
|
|
60
|
+
const { uploadAsset } = useAssets(); // your own API hook
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<UploadDropzone
|
|
64
|
+
accept={['image', 'video']}
|
|
65
|
+
maxSizeMB={50}
|
|
66
|
+
pasteEnabled
|
|
67
|
+
uploadFn={async (files) => {
|
|
68
|
+
for (const file of files) {
|
|
69
|
+
await uploadAsset({ file, name: file.name });
|
|
70
|
+
}
|
|
71
|
+
}}
|
|
72
|
+
/>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Custom Composition (with rpldy)
|
|
53
78
|
|
|
54
79
|
```tsx
|
|
55
80
|
import {
|
|
@@ -116,11 +141,11 @@ Custom overlay:
|
|
|
116
141
|
|
|
117
142
|
### Components
|
|
118
143
|
|
|
119
|
-
| Component | Description |
|
|
120
|
-
|
|
121
|
-
| `Uploader` | All-in-one (Provider + Dropzone + Preview) |
|
|
122
|
-
| `
|
|
123
|
-
| `
|
|
144
|
+
| Component | Needs UploadProvider | Description |
|
|
145
|
+
|-----------|---------------------|-------------|
|
|
146
|
+
| `Uploader` | Yes | All-in-one (Provider + Dropzone + Preview) |
|
|
147
|
+
| `UploadDropzone` | **Optional** | Drag-drop zone — use `uploadFn` for standalone mode |
|
|
148
|
+
| `UploadProvider` | — | Context provider wrapping @rpldy/uploady |
|
|
124
149
|
| `UploadPreviewList` | List of upload items with progress |
|
|
125
150
|
| `UploadPreviewItem` | Single item (thumbnail, status, actions) |
|
|
126
151
|
| `UploadAddButton` | Button to add files |
|
|
@@ -132,6 +157,7 @@ Custom overlay:
|
|
|
132
157
|
|------|-------------|
|
|
133
158
|
| `useUploadEvents` | Subscribe to upload lifecycle events |
|
|
134
159
|
| `useUploadProvider` | Access uploady context |
|
|
160
|
+
| `useClipboardPaste` | Ctrl+V paste-to-upload (files, screenshots, base64, URLs) |
|
|
135
161
|
| `useAbortAll` | Abort all uploads |
|
|
136
162
|
| `useAbortBatch` | Abort batch by ID |
|
|
137
163
|
| `useAbortItem` | Abort single item |
|
|
@@ -208,9 +234,66 @@ Use `onUploadComplete` callback to access raw response:
|
|
|
208
234
|
/>
|
|
209
235
|
```
|
|
210
236
|
|
|
237
|
+
## Paste to Upload (Ctrl+V / Cmd+V)
|
|
238
|
+
|
|
239
|
+
Enable clipboard paste globally via the `pasteEnabled` prop. All cases are handled automatically:
|
|
240
|
+
|
|
241
|
+
| Clipboard content | What happens |
|
|
242
|
+
|-------------------|-------------|
|
|
243
|
+
| File(s) copied from OS | Upload directly |
|
|
244
|
+
| Screenshot (PrintScreen / Cmd+Shift+4) | Detected as `image/png`, uploaded |
|
|
245
|
+
| "Copy Image" from browser | Blob extracted and uploaded |
|
|
246
|
+
| `data:image/png;base64,…` in text | Decoded and uploaded as File |
|
|
247
|
+
| `https://…/photo.jpg` in text | Fetched and uploaded as File |
|
|
248
|
+
| `<img src="…">` in HTML clipboard | src extracted, treated as URL |
|
|
249
|
+
| Plain text, non-image URL | Ignored |
|
|
250
|
+
|
|
251
|
+
```tsx
|
|
252
|
+
// Simplest — enable on the all-in-one component
|
|
253
|
+
<Uploader
|
|
254
|
+
destination="/api/upload"
|
|
255
|
+
accept={['image']}
|
|
256
|
+
pasteEnabled
|
|
257
|
+
onPasteNoMatch={() => toast.info('Nothing to upload in clipboard')}
|
|
258
|
+
/>
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
```tsx
|
|
262
|
+
// Enable on UploadDropzone directly
|
|
263
|
+
<UploadDropzone
|
|
264
|
+
accept={['image', 'video']}
|
|
265
|
+
pasteEnabled
|
|
266
|
+
onPasteNoMatch={() => console.log('no match')}
|
|
267
|
+
/>
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
```tsx
|
|
271
|
+
// Use the hook standalone for custom paste zones
|
|
272
|
+
import { useClipboardPaste } from '@djangocfg/ui-tools/upload';
|
|
273
|
+
import { useUploadProvider } from '@djangocfg/ui-tools/upload';
|
|
274
|
+
|
|
275
|
+
function MyCustomZone() {
|
|
276
|
+
const { upload } = useUploadProvider();
|
|
277
|
+
|
|
278
|
+
useClipboardPaste({
|
|
279
|
+
enabled: true,
|
|
280
|
+
acceptTypes: ['image', 'video'], // MIME prefixes
|
|
281
|
+
maxBytes: 10 * 1024 * 1024, // 10 MB
|
|
282
|
+
onFiles: (files) => upload(files),
|
|
283
|
+
onNoMatch: () => console.log('nothing pasteable'),
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
return <div>Paste here (Ctrl+V)</div>;
|
|
287
|
+
}
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
> **Note:** Paste listeners skip `<input>`, `<textarea>`, and `contentEditable` elements
|
|
291
|
+
> so text editing is never interrupted.
|
|
292
|
+
|
|
211
293
|
## Features
|
|
212
294
|
|
|
213
295
|
- Drag & drop with visual feedback
|
|
296
|
+
- Paste to upload (Ctrl+V) — files, screenshots, images, base64, URLs
|
|
214
297
|
- Multiple file upload
|
|
215
298
|
- Concurrent uploads (configurable)
|
|
216
299
|
- Progress tracking per file
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useState } from 'react';
|
|
2
2
|
import { defineStory, useBoolean, useNumber } from '@djangocfg/playground';
|
|
3
3
|
import { Card, CardContent, CardHeader, CardTitle } from '@djangocfg/ui-core/components';
|
|
4
|
-
import { ImageIcon } from 'lucide-react';
|
|
4
|
+
import { ImageIcon, ClipboardPaste } from 'lucide-react';
|
|
5
5
|
import { Uploader } from './components/Uploader';
|
|
6
6
|
import { logger } from './utils';
|
|
7
7
|
import { UploadProvider } from './context';
|
|
@@ -9,6 +9,8 @@ import { UploadDropzone } from './components/UploadDropzone';
|
|
|
9
9
|
import { UploadPreviewList } from './components/UploadPreviewList';
|
|
10
10
|
import { UploadAddButton } from './components/UploadAddButton';
|
|
11
11
|
import { useUploadEvents } from './hooks/useUploadEvents';
|
|
12
|
+
import { useClipboardPaste } from './hooks/useClipboardPaste';
|
|
13
|
+
import { useUploadProvider } from './hooks/useUploadProvider';
|
|
12
14
|
import type { UploadedAsset, AssetType } from './types';
|
|
13
15
|
|
|
14
16
|
export default defineStory({
|
|
@@ -226,6 +228,75 @@ export const CustomContent = () => (
|
|
|
226
228
|
</div>
|
|
227
229
|
);
|
|
228
230
|
|
|
231
|
+
// Paste to upload
|
|
232
|
+
export const PasteToUpload = () => {
|
|
233
|
+
const [pasteEnabled] = useBoolean('pasteEnabled', {
|
|
234
|
+
defaultValue: true,
|
|
235
|
+
label: 'Paste Enabled (Ctrl+V)',
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
return (
|
|
239
|
+
<div className="max-w-2xl space-y-4">
|
|
240
|
+
<Card>
|
|
241
|
+
<CardContent className="pt-4">
|
|
242
|
+
<p className="text-sm text-muted-foreground flex items-center gap-2">
|
|
243
|
+
<ClipboardPaste className="h-4 w-4" />
|
|
244
|
+
Copy an image anywhere, then press <kbd className="px-1 py-0.5 rounded bg-muted text-xs font-mono">Ctrl+V</kbd> to upload it.
|
|
245
|
+
Supports: files, screenshots, copied images, base64 URLs, remote image URLs.
|
|
246
|
+
</p>
|
|
247
|
+
</CardContent>
|
|
248
|
+
</Card>
|
|
249
|
+
<Uploader
|
|
250
|
+
destination={MOCK_DESTINATION}
|
|
251
|
+
accept={['image']}
|
|
252
|
+
pasteEnabled={pasteEnabled}
|
|
253
|
+
onPasteNoMatch={() => logger.info('Paste: no uploadable content found')}
|
|
254
|
+
/>
|
|
255
|
+
</div>
|
|
256
|
+
);
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
// useClipboardPaste hook standalone
|
|
260
|
+
function ClipboardPasteHookContent() {
|
|
261
|
+
const { upload } = useUploadProvider();
|
|
262
|
+
const [lastPaste, setLastPaste] = useState<string | null>(null);
|
|
263
|
+
|
|
264
|
+
useClipboardPaste({
|
|
265
|
+
enabled: true,
|
|
266
|
+
acceptTypes: ['image'],
|
|
267
|
+
onFiles: (files) => {
|
|
268
|
+
setLastPaste(`Pasted ${files.length} file(s): ${files.map((f) => f.name).join(', ')}`);
|
|
269
|
+
upload(files);
|
|
270
|
+
},
|
|
271
|
+
onNoMatch: () => setLastPaste('Paste detected but no image found in clipboard'),
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
return (
|
|
275
|
+
<div className="space-y-4">
|
|
276
|
+
<Card>
|
|
277
|
+
<CardContent className="pt-4 space-y-2">
|
|
278
|
+
<p className="text-sm text-muted-foreground flex items-center gap-2">
|
|
279
|
+
<ClipboardPaste className="h-4 w-4" />
|
|
280
|
+
Using <code className="text-xs bg-muted px-1 rounded">useClipboardPaste</code> hook directly — paste anywhere on the page.
|
|
281
|
+
</p>
|
|
282
|
+
{lastPaste && (
|
|
283
|
+
<p className="text-xs font-mono bg-muted rounded p-2">{lastPaste}</p>
|
|
284
|
+
)}
|
|
285
|
+
</CardContent>
|
|
286
|
+
</Card>
|
|
287
|
+
<UploadPreviewList />
|
|
288
|
+
</div>
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export const ClipboardPasteHook = () => (
|
|
293
|
+
<div className="max-w-2xl">
|
|
294
|
+
<UploadProvider destination={{ url: MOCK_DESTINATION }}>
|
|
295
|
+
<ClipboardPasteHookContent />
|
|
296
|
+
</UploadProvider>
|
|
297
|
+
</div>
|
|
298
|
+
);
|
|
299
|
+
|
|
229
300
|
// Page-level drop zone
|
|
230
301
|
function PageDropContent() {
|
|
231
302
|
return (
|
|
@@ -298,3 +369,47 @@ export const PageDropCustomOverlay = () => (
|
|
|
298
369
|
</UploadProvider>
|
|
299
370
|
</div>
|
|
300
371
|
);
|
|
372
|
+
|
|
373
|
+
// Standalone — uploadFn instead of UploadProvider (custom API hooks, no rpldy)
|
|
374
|
+
export const StandaloneWithUploadFn = () => {
|
|
375
|
+
const [files, setFiles] = useState<string[]>([]);
|
|
376
|
+
|
|
377
|
+
return (
|
|
378
|
+
<div className="max-w-2xl space-y-4">
|
|
379
|
+
<Card>
|
|
380
|
+
<CardContent className="pt-4">
|
|
381
|
+
<p className="text-sm text-muted-foreground flex items-center gap-2">
|
|
382
|
+
<ClipboardPaste className="h-4 w-4" />
|
|
383
|
+
No <code className="text-xs bg-muted px-1 rounded">UploadProvider</code> needed.
|
|
384
|
+
Pass <code className="text-xs bg-muted px-1 rounded">uploadFn</code> to handle files yourself.
|
|
385
|
+
Drag, click, or paste (Ctrl+V).
|
|
386
|
+
</p>
|
|
387
|
+
</CardContent>
|
|
388
|
+
</Card>
|
|
389
|
+
<UploadDropzone
|
|
390
|
+
accept={['image', 'document']}
|
|
391
|
+
maxSizeMB={10}
|
|
392
|
+
pasteEnabled
|
|
393
|
+
uploadFn={(selected) => {
|
|
394
|
+
setFiles((prev) => [...prev, ...selected.map((f) => f.name)]);
|
|
395
|
+
logger.info('Custom uploadFn received: ' + selected.map((f) => f.name).join(', '));
|
|
396
|
+
}}
|
|
397
|
+
onPasteNoMatch={() => logger.info('Paste: no uploadable content found')}
|
|
398
|
+
/>
|
|
399
|
+
{files.length > 0 && (
|
|
400
|
+
<Card>
|
|
401
|
+
<CardHeader>
|
|
402
|
+
<CardTitle className="text-sm">Received by uploadFn</CardTitle>
|
|
403
|
+
</CardHeader>
|
|
404
|
+
<CardContent>
|
|
405
|
+
<ul className="text-sm space-y-1">
|
|
406
|
+
{files.map((name, i) => (
|
|
407
|
+
<li key={i} className="text-muted-foreground">{name}</li>
|
|
408
|
+
))}
|
|
409
|
+
</ul>
|
|
410
|
+
</CardContent>
|
|
411
|
+
</Card>
|
|
412
|
+
)}
|
|
413
|
+
</div>
|
|
414
|
+
);
|
|
415
|
+
};
|
|
@@ -6,8 +6,20 @@ import { Upload } from 'lucide-react';
|
|
|
6
6
|
import { cn } from '@djangocfg/ui-core/lib';
|
|
7
7
|
import { useT } from '@djangocfg/i18n';
|
|
8
8
|
import { buildAcceptString, logger } from '../utils';
|
|
9
|
+
import { useClipboardPaste } from '../hooks/useClipboardPaste';
|
|
9
10
|
import type { UploadDropzoneProps } from '../types';
|
|
10
11
|
|
|
12
|
+
function useOptionalUploady(uploadFn?: (files: File[]) => void) {
|
|
13
|
+
try {
|
|
14
|
+
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
15
|
+
const { upload } = useUploady();
|
|
16
|
+
return uploadFn ?? upload;
|
|
17
|
+
} catch {
|
|
18
|
+
// Not inside UploadProvider — use uploadFn if provided, otherwise noop
|
|
19
|
+
return uploadFn ?? (() => {});
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
11
23
|
export function UploadDropzone({
|
|
12
24
|
accept = ['image', 'audio', 'video', 'document'],
|
|
13
25
|
multiple = true,
|
|
@@ -17,9 +29,12 @@ export function UploadDropzone({
|
|
|
17
29
|
className,
|
|
18
30
|
children,
|
|
19
31
|
onFilesSelected,
|
|
32
|
+
uploadFn,
|
|
33
|
+
pasteEnabled = true,
|
|
34
|
+
onPasteNoMatch,
|
|
20
35
|
}: UploadDropzoneProps) {
|
|
21
36
|
const t = useT();
|
|
22
|
-
const
|
|
37
|
+
const upload = useOptionalUploady(uploadFn);
|
|
23
38
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
24
39
|
const [isDragging, setIsDragging] = useState(false);
|
|
25
40
|
const dragCounter = useRef(0);
|
|
@@ -69,6 +84,24 @@ export function UploadDropzone({
|
|
|
69
84
|
}
|
|
70
85
|
}, [upload, maxBytes, maxSizeMB, onFilesSelected]);
|
|
71
86
|
|
|
87
|
+
// Build accept MIME types for clipboard paste (e.g. ['image', 'video'] → ['image', 'video'])
|
|
88
|
+
const pasteAcceptTypes = useMemo(
|
|
89
|
+
() => accept.map((assetType) => (assetType === 'document' ? '' : assetType)).filter(Boolean),
|
|
90
|
+
[accept],
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
useClipboardPaste({
|
|
94
|
+
enabled: pasteEnabled && !disabled,
|
|
95
|
+
acceptTypes: pasteAcceptTypes.length ? pasteAcceptTypes : undefined,
|
|
96
|
+
maxBytes: maxBytes,
|
|
97
|
+
onFiles: (files) => {
|
|
98
|
+
const toUpload = multiple ? files : files.slice(0, 1);
|
|
99
|
+
onFilesSelected?.(toUpload);
|
|
100
|
+
upload(toUpload);
|
|
101
|
+
},
|
|
102
|
+
onNoMatch: onPasteNoMatch,
|
|
103
|
+
});
|
|
104
|
+
|
|
72
105
|
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
|
73
106
|
e.preventDefault();
|
|
74
107
|
e.stopPropagation();
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
Skeleton,
|
|
14
14
|
} from '@djangocfg/ui-core/components';
|
|
15
15
|
import { useT } from '@djangocfg/i18n';
|
|
16
|
-
import { formatFileSize } from '../utils';
|
|
16
|
+
import { formatFileSize, truncateFilename } from '../utils';
|
|
17
17
|
import type { UploadPreviewItemProps, AssetType, UploadStatus } from '../types';
|
|
18
18
|
|
|
19
19
|
const ASSET_ICONS: Record<AssetType, typeof FileIcon> = {
|
|
@@ -53,6 +53,7 @@ export function UploadPreviewItem({
|
|
|
53
53
|
}, [file.type]);
|
|
54
54
|
|
|
55
55
|
const fileSize = useMemo(() => formatFileSize(file.size), [file.size]);
|
|
56
|
+
const shortName = useMemo(() => truncateFilename(file.name), [file.name]);
|
|
56
57
|
const progressPercent = Math.round(progress);
|
|
57
58
|
|
|
58
59
|
// Prepare visibility flags
|
|
@@ -144,11 +145,15 @@ export function UploadPreviewItem({
|
|
|
144
145
|
<div className="flex-1 min-w-0">
|
|
145
146
|
<Tooltip>
|
|
146
147
|
<TooltipTrigger asChild>
|
|
147
|
-
<p className="text-sm font-medium
|
|
148
|
+
<p className="text-sm font-medium cursor-default leading-tight">
|
|
149
|
+
{shortName}
|
|
150
|
+
</p>
|
|
148
151
|
</TooltipTrigger>
|
|
149
|
-
|
|
150
|
-
<
|
|
151
|
-
|
|
152
|
+
{shortName !== file.name && (
|
|
153
|
+
<TooltipContent side="top" className="max-w-xs">
|
|
154
|
+
<p className="break-all">{file.name}</p>
|
|
155
|
+
</TooltipContent>
|
|
156
|
+
)}
|
|
152
157
|
</Tooltip>
|
|
153
158
|
|
|
154
159
|
<p className="text-xs text-muted-foreground tabular-nums">{fileSize}</p>
|
|
@@ -12,6 +12,8 @@ interface UploaderContentProps {
|
|
|
12
12
|
multiple?: UploaderProps['multiple'];
|
|
13
13
|
compact?: UploaderProps['compact'];
|
|
14
14
|
showPreview?: UploaderProps['showPreview'];
|
|
15
|
+
pasteEnabled?: UploaderProps['pasteEnabled'];
|
|
16
|
+
onPasteNoMatch?: UploaderProps['onPasteNoMatch'];
|
|
15
17
|
className?: UploaderProps['className'];
|
|
16
18
|
dropzoneClassName?: UploaderProps['dropzoneClassName'];
|
|
17
19
|
previewClassName?: UploaderProps['previewClassName'];
|
|
@@ -24,6 +26,8 @@ function UploaderContent({
|
|
|
24
26
|
multiple,
|
|
25
27
|
compact,
|
|
26
28
|
showPreview,
|
|
29
|
+
pasteEnabled,
|
|
30
|
+
onPasteNoMatch,
|
|
27
31
|
className,
|
|
28
32
|
dropzoneClassName,
|
|
29
33
|
previewClassName,
|
|
@@ -36,6 +40,8 @@ function UploaderContent({
|
|
|
36
40
|
maxSizeMB={maxSizeMB}
|
|
37
41
|
multiple={multiple}
|
|
38
42
|
compact={compact}
|
|
43
|
+
pasteEnabled={pasteEnabled}
|
|
44
|
+
onPasteNoMatch={onPasteNoMatch}
|
|
39
45
|
className={dropzoneClassName}
|
|
40
46
|
>
|
|
41
47
|
{children}
|
|
@@ -1,2 +1,4 @@
|
|
|
1
1
|
export { useUploadEvents } from './useUploadEvents';
|
|
2
2
|
export { useUploadProvider, useAbortAll, useAbortBatch, useAbortItem } from './useUploadProvider';
|
|
3
|
+
export { useClipboardPaste } from './useClipboardPaste';
|
|
4
|
+
export type { ClipboardPasteOptions } from './useClipboardPaste';
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useCallback, useRef, type RefObject } from 'react';
|
|
4
|
+
import { logger } from '../utils/logger';
|
|
5
|
+
|
|
6
|
+
export interface ClipboardPasteOptions {
|
|
7
|
+
/** Whether paste is enabled */
|
|
8
|
+
enabled?: boolean;
|
|
9
|
+
/** Accept only these MIME type prefixes, e.g. ['image'] */
|
|
10
|
+
acceptTypes?: string[];
|
|
11
|
+
/** Max file size in bytes (optional) */
|
|
12
|
+
maxBytes?: number;
|
|
13
|
+
/** Called with resolved File objects from clipboard */
|
|
14
|
+
onFiles: (files: File[]) => void;
|
|
15
|
+
/** Called when paste happened but no suitable content found */
|
|
16
|
+
onNoMatch?: () => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Resolves clipboard data into File objects.
|
|
21
|
+
*
|
|
22
|
+
* Handles all real-world cases:
|
|
23
|
+
* 1. Native file(s) copied from OS (e.g. Finder, Explorer)
|
|
24
|
+
* 2. Screenshots (PrintScreen / Cmd+Shift+4) — come as image/png blob
|
|
25
|
+
* 3. Images copied from browser ("Copy Image") — image/* blob
|
|
26
|
+
* 4. Base64 data: URI in clipboard text (data:image/png;base64,…)
|
|
27
|
+
* 5. Remote image URL in clipboard text (https://…/img.jpg)
|
|
28
|
+
* 6. HTML with <img src="…"> — extracts first src and treats as case 4/5
|
|
29
|
+
*/
|
|
30
|
+
async function resolveClipboardFiles(
|
|
31
|
+
e: ClipboardEvent,
|
|
32
|
+
acceptTypes: string[],
|
|
33
|
+
maxBytes: number | undefined,
|
|
34
|
+
): Promise<File[]> {
|
|
35
|
+
const cd = e.clipboardData;
|
|
36
|
+
if (!cd) return [];
|
|
37
|
+
|
|
38
|
+
// ── 1 & 2 & 3: native files / blobs ─────────────────────────────────────
|
|
39
|
+
const nativeFiles: File[] = [];
|
|
40
|
+
for (const item of Array.from(cd.items)) {
|
|
41
|
+
if (item.kind === 'file') {
|
|
42
|
+
const file = item.getAsFile();
|
|
43
|
+
if (!file) continue;
|
|
44
|
+
if (!matchesAccept(file.type, acceptTypes)) continue;
|
|
45
|
+
if (maxBytes && file.size > maxBytes) {
|
|
46
|
+
logger.warn(`Clipboard file "${file.name}" exceeds size limit`);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
nativeFiles.push(file);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (nativeFiles.length > 0) return nativeFiles;
|
|
53
|
+
|
|
54
|
+
// ── 4 & 5 & 6: text-based payloads ──────────────────────────────────────
|
|
55
|
+
const text = cd.getData('text/plain').trim();
|
|
56
|
+
const html = cd.getData('text/html').trim();
|
|
57
|
+
|
|
58
|
+
// 6: extract src from HTML <img>
|
|
59
|
+
const imgSrc = extractImgSrc(html);
|
|
60
|
+
const source = imgSrc || text;
|
|
61
|
+
|
|
62
|
+
if (!source) return [];
|
|
63
|
+
|
|
64
|
+
// 4: base64 data URI
|
|
65
|
+
if (source.startsWith('data:')) {
|
|
66
|
+
const file = dataUriToFile(source);
|
|
67
|
+
if (!file) return [];
|
|
68
|
+
if (!matchesAccept(file.type, acceptTypes)) return [];
|
|
69
|
+
if (maxBytes && file.size > maxBytes) {
|
|
70
|
+
logger.warn('Pasted base64 image exceeds size limit');
|
|
71
|
+
return [];
|
|
72
|
+
}
|
|
73
|
+
return [file];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 5: remote URL
|
|
77
|
+
if (/^https?:\/\//i.test(source)) {
|
|
78
|
+
const mimeHint = guessMimeFromUrl(source);
|
|
79
|
+
if (!matchesAccept(mimeHint, acceptTypes)) return [];
|
|
80
|
+
try {
|
|
81
|
+
const file = await fetchUrlAsFile(source);
|
|
82
|
+
if (!file) return [];
|
|
83
|
+
if (!matchesAccept(file.type, acceptTypes)) return [];
|
|
84
|
+
if (maxBytes && file.size > maxBytes) {
|
|
85
|
+
logger.warn('Pasted URL image exceeds size limit');
|
|
86
|
+
return [];
|
|
87
|
+
}
|
|
88
|
+
return [file];
|
|
89
|
+
} catch (err) {
|
|
90
|
+
logger.warn('Failed to fetch pasted URL', err);
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return [];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
function matchesAccept(mimeType: string, acceptTypes: string[]): boolean {
|
|
101
|
+
if (!acceptTypes.length) return true;
|
|
102
|
+
return acceptTypes.some((accept) => {
|
|
103
|
+
if (accept.endsWith('/*')) return mimeType.startsWith(accept.slice(0, -1));
|
|
104
|
+
return mimeType === accept || mimeType.startsWith(accept + '/');
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function extractImgSrc(html: string): string | null {
|
|
109
|
+
if (!html) return null;
|
|
110
|
+
const match = html.match(/<img[^>]+src=["']([^"']+)["']/i);
|
|
111
|
+
return match?.[1] ?? null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function dataUriToFile(dataUri: string): File | null {
|
|
115
|
+
try {
|
|
116
|
+
const [header, base64] = dataUri.split(',');
|
|
117
|
+
const mimeMatch = header.match(/data:([^;]+)/);
|
|
118
|
+
if (!mimeMatch) return null;
|
|
119
|
+
const mime = mimeMatch[1];
|
|
120
|
+
const binary = atob(base64);
|
|
121
|
+
const bytes = new Uint8Array(binary.length);
|
|
122
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
123
|
+
const ext = mime.split('/')[1]?.split('+')[0] ?? 'bin';
|
|
124
|
+
return new File([bytes], `paste.${ext}`, { type: mime });
|
|
125
|
+
} catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function guessMimeFromUrl(url: string): string {
|
|
131
|
+
const ext = url.split('?')[0].split('.').pop()?.toLowerCase() ?? '';
|
|
132
|
+
const map: Record<string, string> = {
|
|
133
|
+
jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png',
|
|
134
|
+
gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml',
|
|
135
|
+
avif: 'image/avif', mp4: 'video/mp4', webm: 'video/webm',
|
|
136
|
+
mp3: 'audio/mpeg', wav: 'audio/wav', pdf: 'application/pdf',
|
|
137
|
+
};
|
|
138
|
+
return map[ext] ?? 'application/octet-stream';
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function fetchUrlAsFile(url: string): Promise<File | null> {
|
|
142
|
+
const resp = await fetch(url);
|
|
143
|
+
if (!resp.ok) return null;
|
|
144
|
+
const blob = await resp.blob();
|
|
145
|
+
const ext = url.split('?')[0].split('.').pop()?.toLowerCase() ?? 'bin';
|
|
146
|
+
const name = url.split('/').pop()?.split('?')[0] ?? `paste.${ext}`;
|
|
147
|
+
return new File([blob], name, { type: blob.type || guessMimeFromUrl(url) });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── hook ─────────────────────────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Listens for paste events (Ctrl+V / Cmd+V) on the given element or
|
|
154
|
+
* globally on `document` and resolves clipboard content into File objects.
|
|
155
|
+
*
|
|
156
|
+
* Supports: native files, screenshots, copied images, base64 URIs, remote URLs.
|
|
157
|
+
*/
|
|
158
|
+
export function useClipboardPaste(
|
|
159
|
+
options: ClipboardPasteOptions,
|
|
160
|
+
elementRef?: RefObject<HTMLElement | null>,
|
|
161
|
+
) {
|
|
162
|
+
const {
|
|
163
|
+
enabled = true,
|
|
164
|
+
acceptTypes = ['image'],
|
|
165
|
+
maxBytes,
|
|
166
|
+
onFiles,
|
|
167
|
+
onNoMatch,
|
|
168
|
+
} = options;
|
|
169
|
+
|
|
170
|
+
// Keep stable refs so we don't re-bind the listener on every render
|
|
171
|
+
const onFilesRef = useRef(onFiles);
|
|
172
|
+
const onNoMatchRef = useRef(onNoMatch);
|
|
173
|
+
onFilesRef.current = onFiles;
|
|
174
|
+
onNoMatchRef.current = onNoMatch;
|
|
175
|
+
|
|
176
|
+
const handlePaste = useCallback(
|
|
177
|
+
async (e: Event) => {
|
|
178
|
+
const clipEvent = e as ClipboardEvent;
|
|
179
|
+
// Don't hijack paste inside text inputs / contentEditable
|
|
180
|
+
const target = e.target as HTMLElement;
|
|
181
|
+
if (
|
|
182
|
+
target instanceof HTMLInputElement ||
|
|
183
|
+
target instanceof HTMLTextAreaElement ||
|
|
184
|
+
target.isContentEditable
|
|
185
|
+
) return;
|
|
186
|
+
|
|
187
|
+
const files = await resolveClipboardFiles(clipEvent, acceptTypes, maxBytes);
|
|
188
|
+
if (files.length > 0) {
|
|
189
|
+
e.preventDefault();
|
|
190
|
+
onFilesRef.current(files);
|
|
191
|
+
} else {
|
|
192
|
+
onNoMatchRef.current?.();
|
|
193
|
+
}
|
|
194
|
+
},
|
|
195
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
196
|
+
[acceptTypes.join(','), maxBytes],
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
useEffect(() => {
|
|
200
|
+
if (!enabled) return;
|
|
201
|
+
const target: EventTarget = elementRef?.current ?? document;
|
|
202
|
+
target.addEventListener('paste', handlePaste);
|
|
203
|
+
return () => target.removeEventListener('paste', handlePaste);
|
|
204
|
+
}, [enabled, elementRef, handlePaste]);
|
|
205
|
+
}
|
|
@@ -12,6 +12,8 @@ export { UploadProvider } from './context';
|
|
|
12
12
|
// Hooks
|
|
13
13
|
export { useUploadEvents } from './hooks/useUploadEvents';
|
|
14
14
|
export { useUploadProvider, useAbortAll, useAbortBatch, useAbortItem } from './hooks/useUploadProvider';
|
|
15
|
+
export { useClipboardPaste } from './hooks/useClipboardPaste';
|
|
16
|
+
export type { ClipboardPasteOptions } from './hooks/useClipboardPaste';
|
|
15
17
|
|
|
16
18
|
// Utils
|
|
17
19
|
export {
|
|
@@ -19,6 +21,7 @@ export {
|
|
|
19
21
|
buildAcceptString,
|
|
20
22
|
formatFileSize,
|
|
21
23
|
formatDuration,
|
|
24
|
+
truncateFilename,
|
|
22
25
|
} from './utils';
|
|
23
26
|
|
|
24
27
|
// Types
|
|
@@ -41,6 +41,10 @@ export interface UploaderProps {
|
|
|
41
41
|
autoUpload?: boolean;
|
|
42
42
|
/** Show preview list (default: true) */
|
|
43
43
|
showPreview?: boolean;
|
|
44
|
+
/** Enable Ctrl+V / Cmd+V paste-to-upload (default: true) */
|
|
45
|
+
pasteEnabled?: boolean;
|
|
46
|
+
/** Called when paste fires but no uploadable content was found */
|
|
47
|
+
onPasteNoMatch?: () => void;
|
|
44
48
|
/** Compact mode (smaller dropzone) */
|
|
45
49
|
compact?: boolean;
|
|
46
50
|
/** Max concurrent uploads (default: 3) */
|
|
@@ -81,7 +85,18 @@ export interface UploadDropzoneProps {
|
|
|
81
85
|
disabled?: boolean;
|
|
82
86
|
className?: string;
|
|
83
87
|
children?: React.ReactNode;
|
|
88
|
+
/** Called with validated files on drop/click/paste */
|
|
84
89
|
onFilesSelected?: (files: File[]) => void;
|
|
90
|
+
/**
|
|
91
|
+
* Custom upload function. If provided, rpldy upload is skipped — files go only here.
|
|
92
|
+
* Use this when you handle uploads yourself (e.g. custom API hooks).
|
|
93
|
+
* Requires no UploadProvider ancestor.
|
|
94
|
+
*/
|
|
95
|
+
uploadFn?: (files: File[]) => void;
|
|
96
|
+
/** Enable Ctrl+V / Cmd+V paste-to-upload (default: true) */
|
|
97
|
+
pasteEnabled?: boolean;
|
|
98
|
+
/** Called when paste fires but no uploadable content was found */
|
|
99
|
+
onPasteNoMatch?: () => void;
|
|
85
100
|
}
|
|
86
101
|
|
|
87
102
|
export interface UploadPreviewListProps {
|
|
@@ -1,3 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Middle-truncates a filename preserving the extension and a meaningful suffix.
|
|
3
|
+
*
|
|
4
|
+
* "telegram-cloud-photo-size-5-6152015652357607055-y.jpg"
|
|
5
|
+
* → "telegram-cloud-photo-…7055-y.jpg" (maxLength=32)
|
|
6
|
+
*
|
|
7
|
+
* Short names are returned as-is.
|
|
8
|
+
*/
|
|
9
|
+
export function truncateFilename(filename: string, maxLength = 32): string {
|
|
10
|
+
if (filename.length <= maxLength) return filename;
|
|
11
|
+
|
|
12
|
+
const dotIdx = filename.lastIndexOf('.');
|
|
13
|
+
const ext = dotIdx > 0 ? filename.slice(dotIdx) : ''; // ".jpg"
|
|
14
|
+
const base = dotIdx > 0 ? filename.slice(0, dotIdx) : filename; // without ext
|
|
15
|
+
|
|
16
|
+
// How many chars we can show from the start vs. end of the base
|
|
17
|
+
const available = maxLength - ext.length - 1; // 1 for '…'
|
|
18
|
+
if (available <= 2) return `…${ext}`; // extreme edge case
|
|
19
|
+
|
|
20
|
+
const headLen = Math.ceil(available * 0.55);
|
|
21
|
+
const tailLen = available - headLen;
|
|
22
|
+
|
|
23
|
+
const head = base.slice(0, headLen);
|
|
24
|
+
const tail = tailLen > 0 ? base.slice(-tailLen) : '';
|
|
25
|
+
|
|
26
|
+
return `${head}…${tail}${ext}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
1
29
|
export function formatFileSize(bytes: number): string {
|
|
2
30
|
if (bytes === 0) return '0 B';
|
|
3
31
|
|