Przeglądaj źródła

feat: add drag & paste image support to ChatInput (#4762)

Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: sp.wack <83104063+amanape@users.noreply.github.com>
Xingyao Wang 1 rok temu
rodzic
commit
4a6406ed71

+ 48 - 2
frontend/src/components/chat-input.tsx

@@ -16,6 +16,7 @@ interface ChatInputProps {
   onChange?: (message: string) => void;
   onFocus?: () => void;
   onBlur?: () => void;
+  onImagePaste?: (files: File[]) => void;
   className?: React.HTMLAttributes<HTMLDivElement>["className"];
 }
 
@@ -32,9 +33,46 @@ export function ChatInput({
   onChange,
   onFocus,
   onBlur,
+  onImagePaste,
   className,
 }: ChatInputProps) {
   const textareaRef = React.useRef<HTMLTextAreaElement>(null);
+  const [isDraggingOver, setIsDraggingOver] = React.useState(false);
+
+  const handlePaste = (event: React.ClipboardEvent<HTMLTextAreaElement>) => {
+    event.preventDefault();
+    if (onImagePaste && event.clipboardData.files.length > 0) {
+      const files = Array.from(event.clipboardData.files).filter((file) =>
+        file.type.startsWith("image/"),
+      );
+      if (files.length > 0) onImagePaste(files);
+    }
+  };
+
+  const handleDragOver = (event: React.DragEvent<HTMLTextAreaElement>) => {
+    event.preventDefault();
+    if (event.dataTransfer.types.includes("Files")) {
+      setIsDraggingOver(true);
+    }
+  };
+
+  const handleDragLeave = (event: React.DragEvent<HTMLTextAreaElement>) => {
+    event.preventDefault();
+    setIsDraggingOver(false);
+  };
+
+  const handleDrop = (event: React.DragEvent<HTMLTextAreaElement>) => {
+    event.preventDefault();
+    setIsDraggingOver(false);
+    if (onImagePaste && event.dataTransfer.files.length > 0) {
+      const files = Array.from(event.dataTransfer.files).filter((file) =>
+        file.type.startsWith("image/"),
+      );
+      if (files.length > 0) {
+        onImagePaste(files);
+      }
+    }
+  };
 
   const handleSubmitMessage = () => {
     if (textareaRef.current?.value) {
@@ -67,12 +105,20 @@ export function ChatInput({
         onChange={handleChange}
         onFocus={onFocus}
         onBlur={onBlur}
+        onPaste={handlePaste}
+        onDrop={handleDrop}
+        onDragOver={handleDragOver}
+        onDragLeave={handleDragLeave}
         value={value}
         minRows={1}
         maxRows={maxRows}
+        data-dragging-over={isDraggingOver}
         className={cn(
-          "grow text-sm self-center placeholder:text-neutral-400 text-white resize-none bg-transparent outline-none ring-0",
-          "transition-[height] duration-200 ease-in-out",
+          "grow text-sm self-center placeholder:text-neutral-400 text-white resize-none outline-none ring-0",
+          "transition-all duration-200 ease-in-out",
+          isDraggingOver
+            ? "bg-neutral-600/50 rounded-lg px-2"
+            : "bg-transparent",
           className,
         )}
       />

+ 8 - 0
frontend/src/components/interactive-chat-box.tsx

@@ -53,6 +53,13 @@ export function InteractiveChatBox({
         className={cn(
           "flex items-end gap-1",
           "bg-neutral-700 border border-neutral-600 rounded-lg px-2 py-[10px]",
+          "transition-colors duration-200",
+          "hover:border-neutral-500 focus-within:border-neutral-500",
+          "group relative",
+          "before:pointer-events-none before:absolute before:inset-0 before:rounded-lg before:transition-colors",
+          "before:border-2 before:border-dashed before:border-transparent",
+          "[&:has(*:focus-within)]:before:border-neutral-500/50",
+          "[&:has(*[data-dragging-over='true'])]:before:border-neutral-500/50",
         )}
       >
         <UploadImageInput onUpload={handleUpload} />
@@ -62,6 +69,7 @@ export function InteractiveChatBox({
           placeholder="What do you want to build?"
           onSubmit={handleSubmit}
           onStop={onStop}
+          onImagePaste={handleUpload}
         />
       </div>
     </div>

+ 14 - 1
frontend/src/routes/_oh._index/task-form.tsx

@@ -100,8 +100,14 @@ export function TaskForm({ importedProjectZip }: TaskFormProps) {
         />
         <div
           className={cn(
-            "border border-neutral-600 px-4 py-[17px] rounded-lg text-[17px] leading-5 w-full",
+            "border border-neutral-600 px-4 py-[17px] rounded-lg text-[17px] leading-5 w-full transition-colors duration-200",
             inputIsFocused ? "bg-neutral-600" : "bg-neutral-700",
+            "hover:border-neutral-500 focus-within:border-neutral-500",
+            "group relative",
+            "before:pointer-events-none before:absolute before:inset-0 before:rounded-lg before:transition-colors",
+            "before:border-2 before:border-dashed before:border-transparent",
+            "[&:has(*:focus-within)]:before:border-neutral-500/50",
+            "[&:has(*[data-dragging-over='true'])]:before:border-neutral-500/50",
           )}
         >
           <ChatInput
@@ -112,6 +118,13 @@ export function TaskForm({ importedProjectZip }: TaskFormProps) {
             onChange={(message) => setText(message)}
             onFocus={() => setInputIsFocused(true)}
             onBlur={() => setInputIsFocused(false)}
+            onImagePaste={async (imageFiles) => {
+              const promises = imageFiles.map(convertImageToBase64);
+              const base64Images = await Promise.all(promises);
+              base64Images.forEach((base64) => {
+                dispatch(addFile(base64));
+              });
+            }}
             placeholder={placeholder}
             value={text}
             maxRows={15}