From aa59470e05d32c4e92bbaf16656506b2180f9421 Mon Sep 17 00:00:00 2001 From: wuhaolei Date: Tue, 29 Apr 2025 18:43:30 +0800 Subject: [PATCH] feat: support drag and drop file upload --- src/components/thread/index.tsx | 109 ++++++++++++++++++++++++++++---- 1 file changed, 98 insertions(+), 11 deletions(-) diff --git a/src/components/thread/index.tsx b/src/components/thread/index.tsx index cfd595d..064d156 100644 --- a/src/components/thread/index.tsx +++ b/src/components/thread/index.tsx @@ -39,6 +39,11 @@ import { } from "../ui/tooltip"; import { MessageContentImageUrl } from "@langchain/core/messages"; +interface MessageContentImageUrlWrapper { + id: string; + image: MessageContentImageUrl; +} + function StickyToBottomContent(props: { content: ReactNode; footer?: ReactNode; @@ -115,7 +120,7 @@ export function Thread() { parseAsBoolean.withDefault(false), ); const [input, setInput] = useState(""); - const [imageUrlList, setImageUrlList] = useState( + const [imageUrlList, setImageUrlList] = useState( [], ); const [firstTokenReceived, setFirstTokenReceived] = useState(false); @@ -127,6 +132,8 @@ export function Thread() { const lastError = useRef(undefined); + const dropRef = useRef(null); + useEffect(() => { if (!stream.error) { lastError.current = undefined; @@ -182,7 +189,7 @@ export function Thread() { type: "text", text: input, }, - ...imageUrlList, + ...imageUrlList.map((item) => item.image), ], }; @@ -217,7 +224,7 @@ export function Thread() { resolve({ type: "image_url", image_url: { - url: reader.result as string, + url: reader.result as string }, }); }; @@ -225,7 +232,11 @@ export function Thread() { }); }), ); - setImageUrlList([...imageUrlList, ...imageUrls]); + const wrappedImages = imageUrls.map((image) => ({ + id: uuidv4(), + image, + })); + setImageUrlList([...imageUrlList, ...wrappedImages]); } e.target.value = ""; }; @@ -247,6 +258,79 @@ export function Thread() { (m) => m.type === "ai" || m.type === "tool", ); + useEffect(() => { + if (!dropRef.current) return; + + const handleDragOver = (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + }; + + const handleDrop = async (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + + if (!e.dataTransfer) return; + + const files = Array.from(e.dataTransfer.files); + const imageFiles = files.filter((file) => + file.type.startsWith("image/"), + ); + + if (files.some(file => !file.type.startsWith("image/"))) { + toast.error("You have uploaded invalid file type. Please upload an image."); + } + + if (imageFiles.length) { + const imageUrls = await Promise.all( + Array.from(imageFiles).map((file) => { + return new Promise((resolve) => { + const reader = new FileReader(); + reader.onloadend = () => { + resolve({ + type: "image_url", + image_url: { + url: reader.result as string, + }, + }); + }; + reader.readAsDataURL(file); + }); + }), + ); + const wrappedImages = imageUrls.map((image) => ({ + id: uuidv4(), + image, + })); + setImageUrlList([...imageUrlList, ...wrappedImages]); + } + }; + + const handleDragEnter = (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + }; + + const handleDragLeave = (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + }; + + const element = dropRef.current; + element.addEventListener("dragover", handleDragOver); + element.addEventListener("drop", handleDrop); + element.addEventListener("dragenter", handleDragEnter); + element.addEventListener("dragleave", handleDragLeave); + + return () => { + element.removeEventListener("dragover", handleDragOver); + element.removeEventListener("drop", handleDrop); + element.removeEventListener("dragenter", handleDragEnter); + element.removeEventListener("dragleave", handleDragLeave); + }; + }); + + return (
@@ -430,22 +514,25 @@ export function Thread() { -
+
{imageUrlList.length > 0 && (
- {imageUrlList.map((imageUrl) => { + {imageUrlList.map((imageItemWrapper) => { const imageUrlString = - typeof imageUrl.image_url === "string" - ? imageUrl.image_url - : imageUrl.image_url.url; + typeof imageItemWrapper.image.image_url === "string" + ? imageItemWrapper.image.image_url + : imageItemWrapper.image.image_url.url; return (
setImageUrlList( imageUrlList.filter( - (url) => url !== imageUrl, + (url) => url.id !== imageItemWrapper.id, ), ) }