@djangocfg/ui-tools 2.1.198 → 2.1.201

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.
@@ -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 (
@@ -6,6 +6,7 @@ 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
 
11
12
  export function UploadDropzone({
@@ -17,6 +18,8 @@ export function UploadDropzone({
17
18
  className,
18
19
  children,
19
20
  onFilesSelected,
21
+ pasteEnabled = true,
22
+ onPasteNoMatch,
20
23
  }: UploadDropzoneProps) {
21
24
  const t = useT();
22
25
  const { upload } = useUploady();
@@ -69,6 +72,24 @@ export function UploadDropzone({
69
72
  }
70
73
  }, [upload, maxBytes, maxSizeMB, onFilesSelected]);
71
74
 
75
+ // Build accept MIME types for clipboard paste (e.g. ['image', 'video'] → ['image', 'video'])
76
+ const pasteAcceptTypes = useMemo(
77
+ () => accept.map((assetType) => (assetType === 'document' ? '' : assetType)).filter(Boolean),
78
+ [accept],
79
+ );
80
+
81
+ useClipboardPaste({
82
+ enabled: pasteEnabled && !disabled,
83
+ acceptTypes: pasteAcceptTypes.length ? pasteAcceptTypes : undefined,
84
+ maxBytes: maxBytes,
85
+ onFiles: (files) => {
86
+ const toUpload = multiple ? files : files.slice(0, 1);
87
+ onFilesSelected?.(toUpload);
88
+ upload(toUpload);
89
+ },
90
+ onNoMatch: onPasteNoMatch,
91
+ });
92
+
72
93
  const handleDragEnter = useCallback((e: React.DragEvent) => {
73
94
  e.preventDefault();
74
95
  e.stopPropagation();
@@ -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 {
@@ -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) */
@@ -82,6 +86,10 @@ export interface UploadDropzoneProps {
82
86
  className?: string;
83
87
  children?: React.ReactNode;
84
88
  onFilesSelected?: (files: File[]) => void;
89
+ /** Enable Ctrl+V / Cmd+V paste-to-upload (default: true) */
90
+ pasteEnabled?: boolean;
91
+ /** Called when paste fires but no uploadable content was found */
92
+ onPasteNoMatch?: () => void;
85
93
  }
86
94
 
87
95
  export interface UploadPreviewListProps {