Преглед изворни кода

feat: drag to upload image (#1666)

Yuhao пре 1 година
родитељ
комит
efa16dbb44

+ 7 - 2
web/app/components/app/chat/index.tsx

@@ -23,7 +23,7 @@ import type { DataSet } from '@/models/datasets'
 import ChatImageUploader from '@/app/components/base/image-uploader/chat-image-uploader'
 import ImageList from '@/app/components/base/image-uploader/image-list'
 import { TransferMethod, type VisionFile, type VisionSettings } from '@/types/app'
-import { useClipboardUploader, useImageFiles } from '@/app/components/base/image-uploader/hooks'
+import { useClipboardUploader, useDraggableUploader, useImageFiles } from '@/app/components/base/image-uploader/hooks'
 
 export type IChatProps = {
   configElem?: React.ReactNode
@@ -102,6 +102,7 @@ const Chat: FC<IChatProps> = ({
     onClear,
   } = useImageFiles()
   const { onPaste } = useClipboardUploader({ onUpload, visionConfig, files })
+  const { onDragEnter, onDragLeave, onDragOver, onDrop, isDragActive } = useDraggableUploader<HTMLTextAreaElement>({ onUpload, files, visionConfig })
   const isUseInputMethod = useRef(false)
 
   const [query, setQuery] = React.useState('')
@@ -273,7 +274,7 @@ const Chat: FC<IChatProps> = ({
                   </div>
                 </div>)
             }
-            <div className='p-[5.5px] max-h-[150px] bg-white border-[1.5px] border-gray-200 rounded-xl overflow-y-auto'>
+            <div className={cn('p-[5.5px] max-h-[150px] bg-white border-[1.5px] border-gray-200 rounded-xl overflow-y-auto', isDragActive && 'border-primary-600')}>
               {
                 visionConfig?.enabled && (
                   <>
@@ -307,6 +308,10 @@ const Chat: FC<IChatProps> = ({
                 onKeyUp={handleKeyUp}
                 onKeyDown={handleKeyDown}
                 onPaste={onPaste}
+                onDragEnter={onDragEnter}
+                onDragLeave={onDragLeave}
+                onDragOver={onDragOver}
+                onDrop={onDrop}
                 autoSize
               />
               <div className="absolute bottom-2 right-2 flex items-center h-8">

+ 98 - 18
web/app/components/base/image-uploader/hooks.ts

@@ -111,35 +111,27 @@ export const useImageFiles = () => {
   }
 }
 
-type useClipboardUploaderProps = {
-  files: ImageFile[]
-  visionConfig?: VisionSettings
+type useLocalUploaderProps = {
+  disabled?: boolean
+  limit?: number
   onUpload: (imageFile: ImageFile) => void
 }
 
-export const useClipboardUploader = ({ visionConfig, onUpload, files }: useClipboardUploaderProps) => {
+export const useLocalFileUploader = ({ limit, disabled = false, onUpload }: useLocalUploaderProps) => {
   const { notify } = useToastContext()
   const params = useParams()
   const { t } = useTranslation()
 
-  const handleClipboardPaste = useCallback((e: ClipboardEvent<HTMLTextAreaElement>) => {
-    if (!visionConfig || !visionConfig.enabled)
-      return
-
-    const disabled = files.length >= visionConfig.number_limits
-
-    if (disabled)
+  const handleLocalFileUpload = useCallback((file: File) => {
+    if (disabled) {
       // TODO: leave some warnings?
       return
+    }
 
-    const file = e.clipboardData?.files[0]
-
-    if (!file || !ALLOW_FILE_EXTENSIONS.includes(file.type.split('/')[1]))
+    if (!ALLOW_FILE_EXTENSIONS.includes(file.type.split('/')[1]))
       return
 
-    const limit = +visionConfig.image_file_size_limit!
-
-    if (file.size > limit * 1024 * 1024) {
+    if (limit && file.size > limit * 1024 * 1024) {
       notify({ type: 'error', message: t('common.imageUploader.uploadFromComputerLimit', { size: limit }) })
       return
     }
@@ -182,9 +174,97 @@ export const useClipboardUploader = ({ visionConfig, onUpload, files }: useClipb
       false,
     )
     reader.readAsDataURL(file)
-  }, [visionConfig, files.length, notify, t, onUpload, params.token])
+  }, [disabled, limit, notify, t, onUpload, params.token])
+
+  return { disabled, handleLocalFileUpload }
+}
+
+type useClipboardUploaderProps = {
+  files: ImageFile[]
+  visionConfig?: VisionSettings
+  onUpload: (imageFile: ImageFile) => void
+}
+
+export const useClipboardUploader = ({ visionConfig, onUpload, files }: useClipboardUploaderProps) => {
+  const allowLocalUpload = visionConfig?.transfer_methods?.includes(TransferMethod.local_file)
+  const disabled = useMemo(() =>
+    !visionConfig
+    || !visionConfig?.enabled
+    || !allowLocalUpload
+    || files.length >= visionConfig.number_limits!,
+  [allowLocalUpload, files.length, visionConfig])
+  const limit = useMemo(() => visionConfig ? +visionConfig.image_file_size_limit! : 0, [visionConfig])
+  const { handleLocalFileUpload } = useLocalFileUploader({ limit, onUpload, disabled })
+
+  const handleClipboardPaste = useCallback((e: ClipboardEvent<HTMLTextAreaElement>) => {
+    e.preventDefault()
+    const file = e.clipboardData?.files[0]
+
+    if (!file)
+      return
+
+    handleLocalFileUpload(file)
+  }, [handleLocalFileUpload])
 
   return {
     onPaste: handleClipboardPaste,
   }
 }
+
+type useDraggableUploaderProps = {
+  files: ImageFile[]
+  visionConfig?: VisionSettings
+  onUpload: (imageFile: ImageFile) => void
+}
+
+export const useDraggableUploader = <T extends HTMLElement>({ visionConfig, onUpload, files }: useDraggableUploaderProps) => {
+  const allowLocalUpload = visionConfig?.transfer_methods?.includes(TransferMethod.local_file)
+  const disabled = useMemo(() =>
+    !visionConfig
+    || !visionConfig?.enabled
+    || !allowLocalUpload
+    || files.length >= visionConfig.number_limits!,
+  [allowLocalUpload, files.length, visionConfig])
+  const limit = useMemo(() => visionConfig ? +visionConfig.image_file_size_limit! : 0, [visionConfig])
+  const { handleLocalFileUpload } = useLocalFileUploader({ disabled, onUpload, limit })
+  const [isDragActive, setIsDragActive] = useState(false)
+
+  const handleDragEnter = useCallback((e: React.DragEvent<T>) => {
+    e.preventDefault()
+    e.stopPropagation()
+    if (!disabled)
+      setIsDragActive(true)
+  }, [disabled])
+
+  const handleDragOver = useCallback((e: React.DragEvent<T>) => {
+    e.preventDefault()
+    e.stopPropagation()
+  }, [])
+
+  const handleDragLeave = useCallback((e: React.DragEvent<T>) => {
+    e.preventDefault()
+    e.stopPropagation()
+    setIsDragActive(false)
+  }, [])
+
+  const handleDrop = useCallback((e: React.DragEvent<T>) => {
+    e.preventDefault()
+    e.stopPropagation()
+    setIsDragActive(false)
+
+    const file = e.dataTransfer.files[0]
+
+    if (!file)
+      return
+
+    handleLocalFileUpload(file)
+  }, [handleLocalFileUpload])
+
+  return {
+    onDragEnter: handleDragEnter,
+    onDragOver: handleDragOver,
+    onDragLeave: handleDragLeave,
+    onDrop: handleDrop,
+    isDragActive,
+  }
+}

+ 4 - 51
web/app/components/base/image-uploader/uploader.tsx

@@ -1,11 +1,8 @@
 import type { ChangeEvent, FC } from 'react'
 import { useState } from 'react'
-import { useParams } from 'next/navigation'
-import { useTranslation } from 'react-i18next'
-import { imageUpload } from './utils'
+import { useLocalFileUploader } from './hooks'
 import type { ImageFile } from '@/types/app'
-import { ALLOW_FILE_EXTENSIONS, TransferMethod } from '@/types/app'
-import { useToastContext } from '@/app/components/base/toast'
+import { ALLOW_FILE_EXTENSIONS } from '@/types/app'
 
 type UploaderProps = {
   children: (hovering: boolean) => JSX.Element
@@ -21,9 +18,7 @@ const Uploader: FC<UploaderProps> = ({
   disabled,
 }) => {
   const [hovering, setHovering] = useState(false)
-  const params = useParams()
-  const { notify } = useToastContext()
-  const { t } = useTranslation()
+  const { handleLocalFileUpload } = useLocalFileUploader({ limit, onUpload, disabled })
 
   const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
     const file = e.target.files?.[0]
@@ -31,49 +26,7 @@ const Uploader: FC<UploaderProps> = ({
     if (!file)
       return
 
-    if (limit && file.size > limit * 1024 * 1024) {
-      notify({ type: 'error', message: t('common.imageUploader.uploadFromComputerLimit', { size: limit }) })
-      return
-    }
-
-    const reader = new FileReader()
-    reader.addEventListener(
-      'load',
-      () => {
-        const imageFile = {
-          type: TransferMethod.local_file,
-          _id: `${Date.now()}`,
-          fileId: '',
-          file,
-          url: reader.result as string,
-          base64Url: reader.result as string,
-          progress: 0,
-        }
-        onUpload(imageFile)
-        imageUpload({
-          file: imageFile.file,
-          onProgressCallback: (progress) => {
-            onUpload({ ...imageFile, progress })
-          },
-          onSuccessCallback: (res) => {
-            onUpload({ ...imageFile, fileId: res.id, progress: 100 })
-          },
-          onErrorCallback: () => {
-            notify({ type: 'error', message: t('common.imageUploader.uploadFromComputerUploadError') })
-            onUpload({ ...imageFile, progress: -1 })
-          },
-        }, !!params.token)
-      },
-      false,
-    )
-    reader.addEventListener(
-      'error',
-      () => {
-        notify({ type: 'error', message: t('common.imageUploader.uploadFromComputerReadError') })
-      },
-      false,
-    )
-    reader.readAsDataURL(file)
+    handleLocalFileUpload(file)
   }
 
   return (