Skip to content

Commit d2d4a80

Browse files
committed
feat: add clipboard paste functionality and customizable paste button to file upload component
1 parent ee7de65 commit d2d4a80

File tree

13 files changed

+298
-21
lines changed

13 files changed

+298
-21
lines changed

client/eslint.config.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ export default [
3434
File: 'readonly',
3535
FileReader: 'readonly',
3636
Image: 'readonly',
37+
ClipboardItem: 'readonly',
38+
ClipboardEvent: 'readonly',
39+
navigator: 'readonly',
3740
},
3841
parserOptions: {
3942
ecmaFeatures: {

client/src/components/file-upload/file-upload-item.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
*/
2424
import { Button } from "@heroui/button";
2525
import { HTMLHeroUIProps } from "@heroui/system";
26-
import { CloseIcon } from "@heroui/shared-icons";
26+
import { DeleteIcon } from "@heroui/shared-icons";
2727
import { FC } from "react";
2828

2929
export interface FileUploadItemProps extends HTMLHeroUIProps<"div"> {
@@ -55,7 +55,7 @@ const FileUploadItem: FC<FileUploadItemProps> = ({
5555
role="listitem"
5656
onPress={() => onFileRemove(file)}
5757
>
58-
<CloseIcon />
58+
<DeleteIcon />
5959
</Button>
6060
<span>{file.name}</span>
6161
<span>{formatFileSize(file.size)}</span>

client/src/components/file-upload/file-upload-theme.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ const fileUpload = tv({
4444
"overflow-y-auto",
4545
"subpixel-antialiased",
4646
],
47-
item: ["flex", "gap-4", "my-4"],
47+
item: ["flex", "gap-2", "my-2"],
4848
buttons: [
4949
"flex",
5050
"gap-3",

client/src/components/file-upload/file-upload.tsx

Lines changed: 189 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ import {
3333
useState,
3434
} from "react";
3535
import { clsx } from "@heroui/shared-utils";
36+
import { useTranslation } from "react-i18next";
37+
38+
import { PasteIcon } from "../icons";
3639

3740
import { UseFileUploadProps, useFileUpload } from "./use-file-upload";
3841
import FileUploadItem from "./file-upload-item";
@@ -61,6 +64,9 @@ const FileUpload = forwardRef<"div", FileUploadProps>((props, ref) => {
6164
fileItemElement,
6265
topbar,
6366
onChange,
67+
showPasteButton = false,
68+
pasteButtonText = "Paste",
69+
pasteButton,
6470
...otherProps
6571
} = useFileUpload({ ...props, ref });
6672

@@ -69,6 +75,8 @@ const FileUpload = forwardRef<"div", FileUploadProps>((props, ref) => {
6975
const [files, setFiles] = useState<File[]>(initialFiles ?? []);
7076
const [isDragging, setIsDragging] = useState(false);
7177

78+
const { t } = useTranslation();
79+
7280
useEffect(() => {
7381
initialFiles && setFiles(initialFiles);
7482
}, [initialFiles]);
@@ -177,6 +185,7 @@ const FileUpload = forwardRef<"div", FileUploadProps>((props, ref) => {
177185
const acceptableFiles = filterAcceptableFiles(droppedFiles);
178186

179187
if (acceptableFiles.length === 0) {
188+
// eslint-disable-next-line no-console
180189
console.warn("No acceptable file types were dropped");
181190

182191
return;
@@ -194,6 +203,176 @@ const FileUpload = forwardRef<"div", FileUploadProps>((props, ref) => {
194203
[props.isDisabled, multiple, files, filterAcceptableFiles, updateFiles],
195204
);
196205

206+
// Add new state to track paste errors
207+
const [pasteError, setPasteError] = useState<string | null>(null);
208+
209+
// Function to convert clipboard items to files
210+
const clipboardItemToFile = useCallback(
211+
async (item: ClipboardItem): Promise<File | null> => {
212+
// Get all types from the clipboard item
213+
const types = item.types;
214+
215+
// If accept prop exists, check if the item type is acceptable
216+
if (accept) {
217+
const acceptableTypes = accept.split(",").map((type) => type.trim());
218+
219+
// Find a matching type
220+
const matchingType = types.find((type) => {
221+
// Check for direct match
222+
if (acceptableTypes.includes(type)) return true;
223+
224+
// Check for wildcard matches (e.g., "image/*")
225+
return acceptableTypes.some((acceptType) => {
226+
if (acceptType.endsWith("/*")) {
227+
const category = acceptType.split("/")[0];
228+
229+
return type.startsWith(`${category}/`);
230+
}
231+
232+
return false;
233+
});
234+
});
235+
236+
if (!matchingType) {
237+
// No acceptable types found
238+
return null;
239+
}
240+
241+
// Try to get the blob for the matching type
242+
try {
243+
const blob = await item.getType(matchingType);
244+
245+
// Create a file from the blob
246+
const fileName = `pasted-${new Date().getTime()}.${getExtensionFromMimeType(matchingType)}`;
247+
248+
return new File([blob], fileName, { type: matchingType });
249+
} catch (error) {
250+
// eslint-disable-next-line no-console
251+
console.error("Error getting clipboard item:", error);
252+
253+
return null;
254+
}
255+
} else {
256+
// If no accept prop, try to get the first type
257+
try {
258+
const firstType = types[0];
259+
const blob = await item.getType(firstType);
260+
261+
// Create a file from the blob
262+
const fileName = `pasted-${new Date().getTime()}.${getExtensionFromMimeType(firstType)}`;
263+
264+
return new File([blob], fileName, { type: firstType });
265+
} catch (error) {
266+
// eslint-disable-next-line no-console
267+
console.error("Error getting clipboard item:", error);
268+
269+
return null;
270+
}
271+
}
272+
},
273+
[accept],
274+
);
275+
276+
// Helper function to get file extension from MIME type
277+
const getExtensionFromMimeType = (mimeType: string): string => {
278+
const mimeToExt: Record<string, string> = {
279+
"image/png": "png",
280+
"image/jpeg": "jpg",
281+
"image/jpg": "jpg",
282+
"image/gif": "gif",
283+
"image/webp": "webp",
284+
"image/svg+xml": "svg",
285+
"text/plain": "txt",
286+
"application/pdf": "pdf",
287+
"application/json": "json",
288+
"application/xml": "xml",
289+
"text/html": "html",
290+
"text/csv": "csv",
291+
};
292+
293+
return mimeToExt[mimeType] || "bin";
294+
};
295+
296+
// Function to handle pasting from clipboard
297+
const handlePaste = useCallback(async () => {
298+
try {
299+
setPasteError(null);
300+
301+
// Check if clipboard API is available
302+
if (!navigator.clipboard || !navigator.clipboard.read) {
303+
setPasteError(t("clipboard-api-not-supported-in-this-browser"));
304+
305+
return;
306+
}
307+
308+
// Read clipboard data
309+
const clipboardItems = await navigator.clipboard.read();
310+
311+
if (clipboardItems.length === 0) {
312+
setPasteError(t("clipboard-is-empty"));
313+
314+
return;
315+
}
316+
317+
// Convert clipboard items to files
318+
const newFiles: File[] = [];
319+
320+
for (const item of clipboardItems) {
321+
const file = await clipboardItemToFile(item);
322+
323+
if (file) {
324+
newFiles.push(file);
325+
}
326+
}
327+
328+
if (newFiles.length === 0) {
329+
setPasteError(
330+
t("no-acceptable-content-found-in-clipboard", {
331+
acceptableTypes: accept ? accept : "",
332+
}),
333+
);
334+
335+
return;
336+
}
337+
338+
// Update files based on multiple flag
339+
if (multiple) {
340+
updateFiles([...files, ...newFiles]);
341+
} else {
342+
// If not multiple, just use the first file
343+
updateFiles([newFiles[0]]);
344+
}
345+
} catch (error) {
346+
// eslint-disable-next-line no-console
347+
console.error("Error pasting from clipboard:", error);
348+
setPasteError(t("failed-to-paste-from-clipboard"));
349+
}
350+
}, [multiple, files, clipboardItemToFile, updateFiles, accept]);
351+
352+
// Create paste button element
353+
const pasteButtonElement = useMemo(
354+
() =>
355+
pasteButton ? (
356+
cloneElement(pasteButton, {
357+
disabled: props.isDisabled,
358+
onPress: (ev) => {
359+
handlePaste();
360+
pasteButton.props.onPress?.(ev);
361+
},
362+
})
363+
) : (
364+
<Button
365+
color="secondary"
366+
disabled={props.isDisabled}
367+
startContent={<PasteIcon />}
368+
onPress={handlePaste}
369+
>
370+
{pasteButtonText}
371+
</Button>
372+
),
373+
[pasteButton, pasteButtonText, handlePaste, props.isDisabled],
374+
);
375+
197376
const topbarElement = useMemo(() => {
198377
if (topbar) {
199378
return cloneElement(topbar, {
@@ -308,7 +487,7 @@ const FileUpload = forwardRef<"div", FileUploadProps>((props, ref) => {
308487
})
309488
) : (
310489
<Button
311-
color="warning"
490+
color="primary"
312491
disabled={props.isDisabled}
313492
onPress={() => {
314493
onReset();
@@ -331,6 +510,7 @@ const FileUpload = forwardRef<"div", FileUploadProps>((props, ref) => {
331510
{multiple && files.length !== 0 && addButtonElement}
332511
{files.length !== 0 && resetButtonElement}
333512
{browseButtonElement}
513+
{showPasteButton && pasteButtonElement}
334514
{uploadButtonElement}
335515
</div>
336516
);
@@ -354,6 +534,8 @@ const FileUpload = forwardRef<"div", FileUploadProps>((props, ref) => {
354534
addButtonElement,
355535
resetButtonElement,
356536
uploadButton,
537+
showPasteButton,
538+
pasteButtonElement,
357539
]);
358540

359541
// Add dragOver styles to the base styles
@@ -418,6 +600,12 @@ const FileUpload = forwardRef<"div", FileUploadProps>((props, ref) => {
418600

419601
{topbarElement}
420602

603+
{pasteError && (
604+
<div className="text-danger text-sm p-2 mt-1 bg-danger-50 rounded">
605+
{pasteError}
606+
</div>
607+
)}
608+
421609
{isDragging && (
422610
<div className="absolute inset-0 flex items-center justify-center bg-primary-50 bg-opacity-80 text-primary-600 text-xl font-medium rounded-lg z-10">
423611
{dragDropZoneText}

client/src/components/file-upload/use-file-upload.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,22 @@ interface Props extends Omit<HTMLHeroUIProps<"div">, "onChange"> {
102102
* Triggered when file(s) selected, added or removed.
103103
*/
104104
onChange?: (files: File[]) => void;
105+
/**
106+
* Whether to show the paste button
107+
* @default false
108+
*/
109+
showPasteButton?: boolean;
110+
111+
/**
112+
* Text to display on the paste button
113+
* @default "Paste"
114+
*/
115+
pasteButtonText?: string;
116+
117+
/**
118+
* Custom paste button element
119+
*/
120+
pasteButton?: ReactElement<ButtonProps>;
105121
}
106122

107123
export type UseFileUploadProps = Props & FileUploadVariantProps;

client/src/components/icons.tsx

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,26 @@
1+
/**
2+
* MIT License
3+
*
4+
* Copyright (c) 2025 Ronan LE MEILLAT
5+
*
6+
* Permission is hereby granted, free of charge, to any person obtaining a copy
7+
* of this software and associated documentation files (the "Software"), to deal
8+
* in the Software without restriction, including without limitation the rights
9+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+
* copies of the Software, and to permit persons to whom the Software is
11+
* furnished to do so, subject to the following conditions:
12+
*
13+
* The above copyright notice and this permission notice shall be included in all
14+
* copies or substantial portions of the Software.
15+
*
16+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22+
* SOFTWARE.
23+
*/
124
import * as React from "react";
225

326
import { IconSvgProps } from "@/types";
@@ -268,3 +291,22 @@ export const DownloadIcon = (props: IconSvgProps) => {
268291
</svg>
269292
);
270293
};
294+
295+
export const PasteIcon = (props: IconSvgProps) => {
296+
return (
297+
<svg
298+
fill="none"
299+
height={props.size || props.height || 18}
300+
stroke="currentColor"
301+
strokeLinecap="round"
302+
strokeLinejoin="round"
303+
strokeWidth="2"
304+
viewBox="0 0 24 24"
305+
width={props.size || props.width || 18}
306+
xmlns="http://www.w3.org/2000/svg"
307+
>
308+
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" />
309+
<rect height="4" rx="1" ry="1" width="8" x="8" y="2" />
310+
</svg>
311+
);
312+
};

client/src/locales/base/ar-SA.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,5 +59,9 @@
5959
"download-backup": "تنزيل النسخ الاحتياطي",
6060
"browse": "تصفح",
6161
"drop-your-image-here": "أسقط صورتك هنا",
62-
"reset": "إعادة ضبط"
62+
"reset": "إعادة ضبط",
63+
"paste-from-clipboard": "لصق من الحافظة",
64+
"no-acceptable-content-found-in-clipboard": "لا يوجد محتوى مقبول ({{accessableTypes}}) الموجود في الحافظة",
65+
"clipboard-is-empty": "الحافظة فارغة",
66+
"failed-to-paste-from-clipboard": "فشل لصق من الحافظة"
6367
}

client/src/locales/base/en-US.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,5 +59,10 @@
5959
"download-backup": "Download Backup",
6060
"browse": "Browse",
6161
"reset": "Reset",
62-
"drop-your-image-here": "Drop your image here"
62+
"drop-your-image-here": "Drop your image here",
63+
"paste-from-clipboard": "Paste from Clipboard",
64+
"no-acceptable-content-found-in-clipboard": "No acceptable ({{acceptableTypes}}) content found in clipboard",
65+
"clipboard-is-empty": "Clipboard is empty",
66+
"clipboard-api-not-supported-in-this-browser": "Clipboard API not supported in this browser",
67+
"failed-to-paste-from-clipboard": "Failed to paste from clipboard"
6368
}

client/src/locales/base/es-ES.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,5 +59,9 @@
5959
"download-backup": "Descargar copia de seguridad",
6060
"browse": "Navegar",
6161
"drop-your-image-here": "Deja tu imagen aquí",
62-
"reset": "Reiniciar"
62+
"reset": "Reiniciar",
63+
"paste-from-clipboard": "Pegar del portapapeles",
64+
"no-acceptable-content-found-in-clipboard": "No se encuentra el contenido aceptable ({{aceptableTypes}}) que se encuentra en el portapapeles",
65+
"clipboard-is-empty": "El portapapeles está vacío",
66+
"failed-to-paste-from-clipboard": "No se pudo pegar desde el portapapeles"
6367
}

0 commit comments

Comments
 (0)