diff --git a/web/src/app/image/components/image-composer.tsx b/web/src/app/image/components/image-composer.tsx index 922177d22..9e8b487ed 100644 --- a/web/src/app/image/components/image-composer.tsx +++ b/web/src/app/image/components/image-composer.tsx @@ -1,6 +1,6 @@ "use client"; import { ArrowUp, Check, ChevronDown, ImagePlus, LoaderCircle, X } from "lucide-react"; -import { useEffect, useMemo, useRef, useState, type ClipboardEvent, type RefObject } from "react"; +import { useEffect, useMemo, useRef, useState, type ClipboardEvent, type DragEvent, type RefObject } from "react"; import { ImageLightbox } from "@/components/image-lightbox"; import { Button } from "@/components/ui/button"; @@ -26,6 +26,24 @@ type ImageComposerProps = { onRemoveReferenceImage: (index: number) => void; }; +const imageFileNamePattern = /\.(avif|bmp|gif|heic|heif|ico|jpe?g|png|svg|tiff?|webp)$/i; + +function isImageFile(file: File) { + return file.type.startsWith("image/") || (!file.type && imageFileNamePattern.test(file.name)); +} + +function hasDraggedImages(dataTransfer: DataTransfer) { + const items = Array.from(dataTransfer.items || []); + if (items.length > 0) { + return items.some((item) => item.kind === "file" && (item.type.startsWith("image/") || !item.type)); + } + return Array.from(dataTransfer.files || []).some(isImageFile); +} + +function getDraggedImageFiles(dataTransfer: DataTransfer) { + return Array.from(dataTransfer.files || []).filter(isImageFile); +} + export function ImageComposer({ prompt, imageCount, @@ -46,6 +64,7 @@ export function ImageComposer({ const [lightboxOpen, setLightboxOpen] = useState(false); const [lightboxIndex, setLightboxIndex] = useState(0); const [isSizeMenuOpen, setIsSizeMenuOpen] = useState(false); + const [isDraggingImage, setIsDraggingImage] = useState(false); const [sizeMenuPos, setSizeMenuPos] = useState<{ top: number; left: number }>({ top: 0, left: 0 }); const sizeMenuRef = useRef(null); const sizeMenuBtnRef = useRef(null); @@ -88,6 +107,50 @@ export function ImageComposer({ void onReferenceImageChange(imageFiles); }; + const handleComposerDragEnter = (event: DragEvent) => { + if (!hasDraggedImages(event.dataTransfer)) { + return; + } + + event.preventDefault(); + event.dataTransfer.dropEffect = "copy"; + setIsSizeMenuOpen(false); + setIsDraggingImage(true); + }; + + const handleComposerDragOver = (event: DragEvent) => { + if (!hasDraggedImages(event.dataTransfer)) { + return; + } + + event.preventDefault(); + event.dataTransfer.dropEffect = "copy"; + setIsDraggingImage(true); + }; + + const handleComposerDragLeave = (event: DragEvent) => { + const nextTarget = event.relatedTarget; + if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) { + return; + } + setIsDraggingImage(false); + }; + + const handleComposerDrop = (event: DragEvent) => { + const imageFiles = getDraggedImageFiles(event.dataTransfer); + if (event.dataTransfer.files.length > 0 || imageFiles.length > 0) { + event.preventDefault(); + event.stopPropagation(); + } + + setIsDraggingImage(false); + if (imageFiles.length === 0) { + return; + } + + void onReferenceImageChange(imageFiles); + }; + return (
@@ -137,9 +200,18 @@ export function ImageComposer({
) : null} -
+
{ textareaRef.current?.focus(); }} @@ -169,6 +241,14 @@ export function ImageComposer({ }} className="min-h-[82px] resize-none rounded-[24px] border-0 bg-transparent px-4 pt-4 pb-2 text-[15px] leading-6 text-stone-900 shadow-none placeholder:text-stone-400 focus-visible:ring-0 sm:min-h-[148px] sm:rounded-[32px] sm:px-6 sm:pt-6 sm:pb-20 sm:leading-7" /> + {isDraggingImage ? ( +
+
+ + 松开以上传参考图 +
+
+ ) : null}
event.stopPropagation()}>