dify
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
import type { FC } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
|
||||
type AudioPreviewProps = {
|
||||
url: string
|
||||
title: string
|
||||
onCancel: () => void
|
||||
}
|
||||
const AudioPreview: FC<AudioPreviewProps> = ({
|
||||
url,
|
||||
title,
|
||||
onCancel,
|
||||
}) => {
|
||||
return createPortal(
|
||||
<div className='fixed inset-0 z-[1000] flex items-center justify-center bg-black/80 p-8' onClick={e => e.stopPropagation()}>
|
||||
<div>
|
||||
<audio controls title={title} autoPlay={false} preload="metadata">
|
||||
<source
|
||||
type="audio/mpeg"
|
||||
src={url}
|
||||
className='max-h-full max-w-full'
|
||||
/>
|
||||
</audio>
|
||||
</div>
|
||||
<div
|
||||
className='absolute right-6 top-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-white/[0.08] backdrop-blur-[2px]'
|
||||
onClick={onCancel}
|
||||
>
|
||||
<RiCloseLine className='h-4 w-4 text-gray-500'/>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
export default AudioPreview
|
||||
@@ -0,0 +1,159 @@
|
||||
import type { FC } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Uploader from './uploader'
|
||||
import ImageLinkInput from './image-link-input'
|
||||
import cn from '@/utils/classnames'
|
||||
import { ImagePlus } from '@/app/components/base/icons/src/vender/line/images'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import { Upload03 } from '@/app/components/base/icons/src/vender/line/general'
|
||||
import type { ImageFile, VisionSettings } from '@/types/app'
|
||||
|
||||
type UploadOnlyFromLocalProps = {
|
||||
onUpload: (imageFile: ImageFile) => void
|
||||
disabled?: boolean
|
||||
limit?: number
|
||||
}
|
||||
const UploadOnlyFromLocal: FC<UploadOnlyFromLocalProps> = ({
|
||||
onUpload,
|
||||
disabled,
|
||||
limit,
|
||||
}) => {
|
||||
return (
|
||||
<Uploader onUpload={onUpload} disabled={disabled} limit={limit}>
|
||||
{hovering => (
|
||||
<div
|
||||
className={`
|
||||
relative flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg
|
||||
${hovering && 'bg-gray-100'}
|
||||
`}
|
||||
>
|
||||
<ImagePlus className="h-4 w-4 text-gray-500" />
|
||||
</div>
|
||||
)}
|
||||
</Uploader>
|
||||
)
|
||||
}
|
||||
|
||||
type UploaderButtonProps = {
|
||||
methods: VisionSettings['transfer_methods']
|
||||
onUpload: (imageFile: ImageFile) => void
|
||||
disabled?: boolean
|
||||
limit?: number
|
||||
}
|
||||
const UploaderButton: FC<UploaderButtonProps> = ({
|
||||
methods,
|
||||
onUpload,
|
||||
disabled,
|
||||
limit,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const hasUploadFromLocal = methods.find(
|
||||
method => method === TransferMethod.local_file,
|
||||
)
|
||||
|
||||
const handleUpload = (imageFile: ImageFile) => {
|
||||
onUpload(imageFile)
|
||||
}
|
||||
|
||||
const closePopover = () => setOpen(false)
|
||||
|
||||
const handleToggle = () => {
|
||||
if (disabled)
|
||||
return
|
||||
|
||||
setOpen(v => !v)
|
||||
}
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="top-start"
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={handleToggle}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
className="relative flex h-8 w-8 items-center justify-center rounded-lg enabled:hover:bg-gray-100 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ImagePlus className="h-4 w-4 text-gray-500" />
|
||||
</button>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-50">
|
||||
<div className="w-[260px] rounded-lg border-[0.5px] border-gray-200 bg-white p-2 shadow-lg">
|
||||
<ImageLinkInput onUpload={handleUpload} disabled={disabled} />
|
||||
{hasUploadFromLocal && (
|
||||
<>
|
||||
<div className="mt-2 flex items-center px-2 text-xs font-medium text-gray-400">
|
||||
<div className="mr-3 h-px w-[93px] bg-gradient-to-l from-[#F3F4F6]" />
|
||||
OR
|
||||
<div className="ml-3 h-px w-[93px] bg-gradient-to-r from-[#F3F4F6]" />
|
||||
</div>
|
||||
<Uploader
|
||||
onUpload={handleUpload}
|
||||
limit={limit}
|
||||
closePopover={closePopover}
|
||||
>
|
||||
{hovering => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-8 cursor-pointer items-center justify-center rounded-lg text-[13px] font-medium text-[#155EEF]',
|
||||
hovering && 'bg-primary-50',
|
||||
)}
|
||||
>
|
||||
<Upload03 className="mr-1 h-4 w-4" />
|
||||
{t('common.imageUploader.uploadFromComputer')}
|
||||
</div>
|
||||
)}
|
||||
</Uploader>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
|
||||
type ChatImageUploaderProps = {
|
||||
settings: VisionSettings
|
||||
onUpload: (imageFile: ImageFile) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
const ChatImageUploader: FC<ChatImageUploaderProps> = ({
|
||||
settings,
|
||||
onUpload,
|
||||
disabled,
|
||||
}) => {
|
||||
const onlyUploadLocal
|
||||
= settings.transfer_methods.length === 1
|
||||
&& settings.transfer_methods[0] === TransferMethod.local_file
|
||||
|
||||
if (onlyUploadLocal) {
|
||||
return (
|
||||
<UploadOnlyFromLocal
|
||||
onUpload={onUpload}
|
||||
disabled={disabled}
|
||||
limit={+settings.image_file_size_limit!}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<UploaderButton
|
||||
methods={settings.transfer_methods}
|
||||
onUpload={onUpload}
|
||||
disabled={disabled}
|
||||
limit={+settings.image_file_size_limit!}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default ChatImageUploader
|
||||
272
dify/web/app/components/base/image-uploader/hooks.ts
Normal file
272
dify/web/app/components/base/image-uploader/hooks.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import type { ClipboardEvent } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getImageUploadErrorMessage, imageUpload } from './utils'
|
||||
import { useToastContext } from '@/app/components/base/toast'
|
||||
import { ALLOW_FILE_EXTENSIONS, TransferMethod } from '@/types/app'
|
||||
import type { ImageFile, VisionSettings } from '@/types/app'
|
||||
|
||||
export const useImageFiles = () => {
|
||||
const params = useParams()
|
||||
const { t } = useTranslation()
|
||||
const { notify } = useToastContext()
|
||||
const [files, setFiles] = useState<ImageFile[]>([])
|
||||
const filesRef = useRef<ImageFile[]>([])
|
||||
|
||||
const handleUpload = (imageFile: ImageFile) => {
|
||||
const files = filesRef.current
|
||||
const index = files.findIndex(file => file._id === imageFile._id)
|
||||
|
||||
if (index > -1) {
|
||||
const currentFile = files[index]
|
||||
const newFiles = [...files.slice(0, index), { ...currentFile, ...imageFile }, ...files.slice(index + 1)]
|
||||
setFiles(newFiles)
|
||||
filesRef.current = newFiles
|
||||
}
|
||||
else {
|
||||
const newFiles = [...files, imageFile]
|
||||
setFiles(newFiles)
|
||||
filesRef.current = newFiles
|
||||
}
|
||||
}
|
||||
const handleRemove = (imageFileId: string) => {
|
||||
const files = filesRef.current
|
||||
const index = files.findIndex(file => file._id === imageFileId)
|
||||
|
||||
if (index > -1) {
|
||||
const currentFile = files[index]
|
||||
const newFiles = [...files.slice(0, index), { ...currentFile, deleted: true }, ...files.slice(index + 1)]
|
||||
setFiles(newFiles)
|
||||
filesRef.current = newFiles
|
||||
}
|
||||
}
|
||||
const handleImageLinkLoadError = (imageFileId: string) => {
|
||||
const files = filesRef.current
|
||||
const index = files.findIndex(file => file._id === imageFileId)
|
||||
|
||||
if (index > -1) {
|
||||
const currentFile = files[index]
|
||||
const newFiles = [...files.slice(0, index), { ...currentFile, progress: -1 }, ...files.slice(index + 1)]
|
||||
filesRef.current = newFiles
|
||||
setFiles(newFiles)
|
||||
}
|
||||
}
|
||||
const handleImageLinkLoadSuccess = (imageFileId: string) => {
|
||||
const files = filesRef.current
|
||||
const index = files.findIndex(file => file._id === imageFileId)
|
||||
|
||||
if (index > -1) {
|
||||
const currentImageFile = files[index]
|
||||
const newFiles = [...files.slice(0, index), { ...currentImageFile, progress: 100 }, ...files.slice(index + 1)]
|
||||
filesRef.current = newFiles
|
||||
setFiles(newFiles)
|
||||
}
|
||||
}
|
||||
const handleReUpload = (imageFileId: string) => {
|
||||
const files = filesRef.current
|
||||
const index = files.findIndex(file => file._id === imageFileId)
|
||||
|
||||
if (index > -1) {
|
||||
const currentImageFile = files[index]
|
||||
imageUpload({
|
||||
file: currentImageFile.file!,
|
||||
onProgressCallback: (progress) => {
|
||||
const newFiles = [...files.slice(0, index), { ...currentImageFile, progress }, ...files.slice(index + 1)]
|
||||
filesRef.current = newFiles
|
||||
setFiles(newFiles)
|
||||
},
|
||||
onSuccessCallback: (res) => {
|
||||
const newFiles = [...files.slice(0, index), { ...currentImageFile, fileId: res.id, progress: 100 }, ...files.slice(index + 1)]
|
||||
filesRef.current = newFiles
|
||||
setFiles(newFiles)
|
||||
},
|
||||
onErrorCallback: (error?: any) => {
|
||||
const errorMessage = getImageUploadErrorMessage(error, t('common.imageUploader.uploadFromComputerUploadError'), t)
|
||||
notify({ type: 'error', message: errorMessage })
|
||||
const newFiles = [...files.slice(0, index), { ...currentImageFile, progress: -1 }, ...files.slice(index + 1)]
|
||||
filesRef.current = newFiles
|
||||
setFiles(newFiles)
|
||||
},
|
||||
}, !!params.token)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClear = () => {
|
||||
setFiles([])
|
||||
filesRef.current = []
|
||||
}
|
||||
|
||||
const filteredFiles = useMemo(() => {
|
||||
return files.filter(file => !file.deleted)
|
||||
}, [files])
|
||||
|
||||
return {
|
||||
files: filteredFiles,
|
||||
onUpload: handleUpload,
|
||||
onRemove: handleRemove,
|
||||
onImageLinkLoadError: handleImageLinkLoadError,
|
||||
onImageLinkLoadSuccess: handleImageLinkLoadSuccess,
|
||||
onReUpload: handleReUpload,
|
||||
onClear: handleClear,
|
||||
}
|
||||
}
|
||||
|
||||
type useLocalUploaderProps = {
|
||||
disabled?: boolean
|
||||
limit?: number
|
||||
onUpload: (imageFile: ImageFile) => void
|
||||
}
|
||||
|
||||
export const useLocalFileUploader = ({ limit, disabled = false, onUpload }: useLocalUploaderProps) => {
|
||||
const { notify } = useToastContext()
|
||||
const params = useParams()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const handleLocalFileUpload = useCallback((file: File) => {
|
||||
if (disabled) {
|
||||
// TODO: leave some warnings?
|
||||
return
|
||||
}
|
||||
|
||||
if (!ALLOW_FILE_EXTENSIONS.includes(file.type.split('/')[1]))
|
||||
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: (error?: any) => {
|
||||
const errorMessage = getImageUploadErrorMessage(error, t('common.imageUploader.uploadFromComputerUploadError'), t)
|
||||
notify({ type: 'error', message: errorMessage })
|
||||
onUpload({ ...imageFile, progress: -1 })
|
||||
},
|
||||
}, !!params.token)
|
||||
},
|
||||
false,
|
||||
)
|
||||
reader.addEventListener(
|
||||
'error',
|
||||
() => {
|
||||
notify({ type: 'error', message: t('common.imageUploader.uploadFromComputerReadError') })
|
||||
},
|
||||
false,
|
||||
)
|
||||
reader.readAsDataURL(file)
|
||||
}, [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>) => {
|
||||
// reserve native text copy behavior
|
||||
const file = e.clipboardData?.files[0]
|
||||
// when copied file, prevent default action
|
||||
if (file) {
|
||||
e.preventDefault()
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { FC } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import type { ImageFile } from '@/types/app'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
|
||||
type ImageLinkInputProps = {
|
||||
onUpload: (imageFile: ImageFile) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
const regex = /^(https?|ftp):\/\//
|
||||
const ImageLinkInput: FC<ImageLinkInputProps> = ({
|
||||
onUpload,
|
||||
disabled,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [imageLink, setImageLink] = useState('')
|
||||
|
||||
const handleClick = () => {
|
||||
if (disabled)
|
||||
return
|
||||
|
||||
const imageFile = {
|
||||
type: TransferMethod.remote_url,
|
||||
_id: `${Date.now()}`,
|
||||
fileId: '',
|
||||
progress: regex.test(imageLink) ? 0 : -1,
|
||||
url: imageLink,
|
||||
}
|
||||
|
||||
onUpload(imageFile)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex h-8 items-center rounded-lg border border-components-panel-border bg-components-panel-bg pl-1.5 pr-1 shadow-xs'>
|
||||
<input
|
||||
type="text"
|
||||
className='mr-0.5 h-[18px] grow appearance-none bg-transparent px-1 text-[13px] text-text-primary outline-none'
|
||||
value={imageLink}
|
||||
onChange={e => setImageLink(e.target.value)}
|
||||
placeholder={t('common.imageUploader.pasteImageLinkInputPlaceholder') || ''}
|
||||
/>
|
||||
<Button
|
||||
variant='primary'
|
||||
size='small'
|
||||
disabled={!imageLink || disabled}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{t('common.operation.ok')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ImageLinkInput
|
||||
@@ -0,0 +1,182 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { useMemo, useState } from 'react'
|
||||
import ImageList from './image-list'
|
||||
import ImageLinkInput from './image-link-input'
|
||||
import type { ImageFile } from '@/types/app'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
|
||||
const SAMPLE_BASE64
|
||||
= 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAADICAYAAACtWK6eAAAACXBIWXMAAAsSAAALEgHS3X78AAABbElEQVR4nO3SsQkAIBDARMT+V20sTg6LXhWEATnnMHDx4sWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFu2r/H3n4BG518Gr4AAAAASUVORK5CYII='
|
||||
|
||||
const createRemoteImage = (
|
||||
id: string,
|
||||
progress: number,
|
||||
url: string,
|
||||
): ImageFile => ({
|
||||
type: TransferMethod.remote_url,
|
||||
_id: id,
|
||||
fileId: `remote-${id}`,
|
||||
progress,
|
||||
url,
|
||||
})
|
||||
|
||||
const createLocalImage = (id: string, progress: number): ImageFile => ({
|
||||
type: TransferMethod.local_file,
|
||||
_id: id,
|
||||
fileId: `local-${id}`,
|
||||
progress,
|
||||
url: SAMPLE_BASE64,
|
||||
base64Url: SAMPLE_BASE64,
|
||||
})
|
||||
|
||||
const initialImages: ImageFile[] = [
|
||||
createLocalImage('local-initial', 100),
|
||||
createRemoteImage(
|
||||
'remote-loading',
|
||||
40,
|
||||
'https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&fit=crop&w=300&q=80',
|
||||
),
|
||||
{
|
||||
...createRemoteImage(
|
||||
'remote-error',
|
||||
-1,
|
||||
'https://example.com/not-an-image.jpg',
|
||||
),
|
||||
url: 'https://example.com/not-an-image.jpg',
|
||||
},
|
||||
]
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Data Entry/ImageList',
|
||||
component: ImageList,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Renders thumbnails for uploaded images and manages their states like uploading, error, and deletion.',
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
list: { control: false },
|
||||
onRemove: { control: false },
|
||||
onReUpload: { control: false },
|
||||
onImageLinkLoadError: { control: false },
|
||||
onImageLinkLoadSuccess: { control: false },
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof ImageList>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const ImageUploaderPlayground = ({ readonly }: Story['args']) => {
|
||||
const [images, setImages] = useState<ImageFile[]>(() => initialImages)
|
||||
|
||||
const activeImages = useMemo(() => images.filter(item => !item.deleted), [images])
|
||||
|
||||
const handleRemove = (id: string) => {
|
||||
setImages(prev => prev.map(item => (item._id === id ? { ...item, deleted: true } : item)))
|
||||
}
|
||||
|
||||
const handleReUpload = (id: string) => {
|
||||
setImages(prev => prev.map((item) => {
|
||||
if (item._id !== id)
|
||||
return item
|
||||
|
||||
return {
|
||||
...item,
|
||||
progress: 60,
|
||||
}
|
||||
}))
|
||||
|
||||
setTimeout(() => {
|
||||
setImages(prev => prev.map((item) => {
|
||||
if (item._id !== id)
|
||||
return item
|
||||
|
||||
return {
|
||||
...item,
|
||||
progress: 100,
|
||||
}
|
||||
}))
|
||||
}, 1200)
|
||||
}
|
||||
|
||||
const handleImageLinkLoadSuccess = (id: string) => {
|
||||
setImages(prev => prev.map(item => (item._id === id ? { ...item, progress: 100 } : item)))
|
||||
}
|
||||
|
||||
const handleImageLinkLoadError = (id: string) => {
|
||||
setImages(prev => prev.map(item => (item._id === id ? { ...item, progress: -1 } : item)))
|
||||
}
|
||||
|
||||
const handleUploadFromLink = (imageFile: ImageFile) => {
|
||||
setImages(prev => [
|
||||
...prev,
|
||||
{
|
||||
...imageFile,
|
||||
fileId: `remote-${imageFile._id}`,
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
const handleAddLocalImage = () => {
|
||||
const id = `local-${Date.now()}`
|
||||
setImages(prev => [
|
||||
...prev,
|
||||
createLocalImage(id, 100),
|
||||
])
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-[360px] flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xs font-medium uppercase tracking-[0.18em] text-text-tertiary">Add images</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<ImageLinkInput onUpload={handleUploadFromLink} disabled={readonly} />
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-divider-subtle px-2 py-1 text-xs font-medium text-text-secondary hover:bg-state-base-hover disabled:cursor-not-allowed disabled:text-text-tertiary"
|
||||
onClick={handleAddLocalImage}
|
||||
disabled={readonly}
|
||||
>
|
||||
Simulate local
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ImageList
|
||||
list={activeImages}
|
||||
readonly={readonly}
|
||||
onRemove={handleRemove}
|
||||
onReUpload={handleReUpload}
|
||||
onImageLinkLoadSuccess={handleImageLinkLoadSuccess}
|
||||
onImageLinkLoadError={handleImageLinkLoadError}
|
||||
/>
|
||||
|
||||
<div className="rounded-lg border border-divider-subtle bg-background-default p-2">
|
||||
<span className="mb-1 block text-[11px] font-semibold uppercase tracking-[0.1em] text-text-tertiary">
|
||||
Files state
|
||||
</span>
|
||||
<pre className="max-h-40 overflow-auto text-[11px] leading-relaxed text-text-tertiary">
|
||||
{JSON.stringify(activeImages, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Playground: Story = {
|
||||
render: args => <ImageUploaderPlayground {...args} />,
|
||||
args: {
|
||||
list: [],
|
||||
},
|
||||
}
|
||||
|
||||
export const ReadonlyList: Story = {
|
||||
render: args => <ImageUploaderPlayground {...args} />,
|
||||
args: {
|
||||
list: [],
|
||||
},
|
||||
}
|
||||
143
dify/web/app/components/base/image-uploader/image-list.tsx
Normal file
143
dify/web/app/components/base/image-uploader/image-list.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import type { FC } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
RiCloseLine,
|
||||
RiLoader2Line,
|
||||
} from '@remixicon/react'
|
||||
import cn from '@/utils/classnames'
|
||||
import { RefreshCcw01 } from '@/app/components/base/icons/src/vender/line/arrows'
|
||||
import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import type { ImageFile } from '@/types/app'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import ImagePreview from '@/app/components/base/image-uploader/image-preview'
|
||||
|
||||
type ImageListProps = {
|
||||
list: ImageFile[]
|
||||
readonly?: boolean
|
||||
onRemove?: (imageFileId: string) => void
|
||||
onReUpload?: (imageFileId: string) => void
|
||||
onImageLinkLoadSuccess?: (imageFileId: string) => void
|
||||
onImageLinkLoadError?: (imageFileId: string) => void
|
||||
}
|
||||
|
||||
const ImageList: FC<ImageListProps> = ({
|
||||
list,
|
||||
readonly,
|
||||
onRemove,
|
||||
onReUpload,
|
||||
onImageLinkLoadSuccess,
|
||||
onImageLinkLoadError,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [imagePreviewUrl, setImagePreviewUrl] = useState('')
|
||||
|
||||
const handleImageLinkLoadSuccess = (item: ImageFile) => {
|
||||
if (
|
||||
item.type === TransferMethod.remote_url
|
||||
&& onImageLinkLoadSuccess
|
||||
&& item.progress !== -1
|
||||
)
|
||||
onImageLinkLoadSuccess(item._id)
|
||||
}
|
||||
const handleImageLinkLoadError = (item: ImageFile) => {
|
||||
if (item.type === TransferMethod.remote_url && onImageLinkLoadError)
|
||||
onImageLinkLoadError(item._id)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap">
|
||||
{list.map(item => (
|
||||
<div
|
||||
key={item._id}
|
||||
className="group relative mr-1 rounded-lg border-[0.5px] border-black/5"
|
||||
>
|
||||
{item.type === TransferMethod.local_file && item.progress !== 100 && (
|
||||
<>
|
||||
<div
|
||||
className="absolute inset-0 z-[1] flex items-center justify-center bg-black/30"
|
||||
style={{ left: item.progress > -1 ? `${item.progress}%` : 0 }}
|
||||
>
|
||||
{item.progress === -1 && (
|
||||
<RefreshCcw01
|
||||
className="h-5 w-5 text-white"
|
||||
onClick={() => onReUpload?.(item._id)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{item.progress > -1 && (
|
||||
<span className="absolute left-[50%] top-[50%] z-[1] translate-x-[-50%] translate-y-[-50%] text-sm text-white mix-blend-lighten">
|
||||
{item.progress}%
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{item.type === TransferMethod.remote_url && item.progress !== 100 && (
|
||||
<div
|
||||
className={`
|
||||
absolute inset-0 z-[1] flex items-center justify-center rounded-lg border
|
||||
${item.progress === -1
|
||||
? 'border-[#DC6803] bg-[#FEF0C7]'
|
||||
: 'border-transparent bg-black/[0.16]'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{item.progress > -1 && (
|
||||
<RiLoader2Line className="h-5 w-5 animate-spin text-white" />
|
||||
)}
|
||||
{item.progress === -1 && (
|
||||
<Tooltip
|
||||
popupContent={t('common.imageUploader.pasteImageLinkInvalid')}
|
||||
>
|
||||
<AlertTriangle className="h-4 w-4 text-[#DC6803]" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<img
|
||||
className="h-16 w-16 cursor-pointer rounded-lg border-[0.5px] border-black/5 object-cover"
|
||||
alt={item.file?.name}
|
||||
onLoad={() => handleImageLinkLoadSuccess(item)}
|
||||
onError={() => handleImageLinkLoadError(item)}
|
||||
src={
|
||||
item.type === TransferMethod.remote_url
|
||||
? item.url
|
||||
: item.base64Url
|
||||
}
|
||||
onClick={() =>
|
||||
item.progress === 100
|
||||
&& setImagePreviewUrl(
|
||||
(item.type === TransferMethod.remote_url
|
||||
? item.url
|
||||
: item.base64Url) as string,
|
||||
)
|
||||
}
|
||||
/>
|
||||
{!readonly && (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'absolute -right-[9px] -top-[9px] z-10 h-[18px] w-[18px] items-center justify-center',
|
||||
'rounded-2xl shadow-lg hover:bg-state-base-hover',
|
||||
item.progress === -1 ? 'flex' : 'hidden group-hover:flex',
|
||||
)}
|
||||
onClick={() => onRemove?.(item._id)}
|
||||
>
|
||||
<RiCloseLine className="h-3 w-3 text-text-tertiary" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{imagePreviewUrl && (
|
||||
<ImagePreview
|
||||
url={imagePreviewUrl}
|
||||
onCancel={() => setImagePreviewUrl('')}
|
||||
title=''
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ImageList
|
||||
269
dify/web/app/components/base/image-uploader/image-preview.tsx
Normal file
269
dify/web/app/components/base/image-uploader/image-preview.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
import type { FC } from 'react'
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { t } from 'i18next'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { RiAddBoxLine, RiCloseLine, RiDownloadCloud2Line, RiFileCopyLine, RiZoomInLine, RiZoomOutLine } from '@remixicon/react'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { noop } from 'lodash-es'
|
||||
|
||||
type ImagePreviewProps = {
|
||||
url: string
|
||||
title: string
|
||||
onCancel: () => void
|
||||
onPrev?: () => void
|
||||
onNext?: () => void
|
||||
}
|
||||
|
||||
const isBase64 = (str: string): boolean => {
|
||||
try {
|
||||
return btoa(atob(str)) === str
|
||||
}
|
||||
catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const ImagePreview: FC<ImagePreviewProps> = ({
|
||||
url,
|
||||
title,
|
||||
onCancel,
|
||||
onPrev,
|
||||
onNext,
|
||||
}) => {
|
||||
const [scale, setScale] = useState(1)
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 })
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const imgRef = useRef<HTMLImageElement>(null)
|
||||
const dragStartRef = useRef({ x: 0, y: 0 })
|
||||
const [isCopied, setIsCopied] = useState(false)
|
||||
|
||||
const openInNewTab = () => {
|
||||
// Open in a new window, considering the case when the page is inside an iframe
|
||||
if (url.startsWith('http') || url.startsWith('https')) {
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
else if (url.startsWith('data:image')) {
|
||||
// Base64 image
|
||||
const win = window.open()
|
||||
win?.document.write(`<img src="${url}" alt="${title}" />`)
|
||||
}
|
||||
else {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: `Unable to open image: ${url}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const downloadImage = () => {
|
||||
// Open in a new window, considering the case when the page is inside an iframe
|
||||
if (url.startsWith('http') || url.startsWith('https')) {
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.target = '_blank'
|
||||
a.download = title
|
||||
a.click()
|
||||
}
|
||||
else if (url.startsWith('data:image')) {
|
||||
// Base64 image
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.target = '_blank'
|
||||
a.download = title
|
||||
a.click()
|
||||
}
|
||||
else {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: `Unable to open image: ${url}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const zoomIn = () => {
|
||||
setScale(prevScale => Math.min(prevScale * 1.2, 15))
|
||||
}
|
||||
|
||||
const zoomOut = () => {
|
||||
setScale((prevScale) => {
|
||||
const newScale = Math.max(prevScale / 1.2, 0.5)
|
||||
if (newScale === 1)
|
||||
setPosition({ x: 0, y: 0 }) // Reset position when fully zoomed out
|
||||
|
||||
return newScale
|
||||
})
|
||||
}
|
||||
|
||||
const imageBase64ToBlob = (base64: string, type = 'image/png'): Blob => {
|
||||
const byteCharacters = atob(base64)
|
||||
const byteArrays = []
|
||||
|
||||
for (let offset = 0; offset < byteCharacters.length; offset += 512) {
|
||||
const slice = byteCharacters.slice(offset, offset + 512)
|
||||
const byteNumbers = Array.from({ length: slice.length })
|
||||
for (let i = 0; i < slice.length; i++)
|
||||
byteNumbers[i] = slice.charCodeAt(i)
|
||||
|
||||
const byteArray = new Uint8Array(byteNumbers as any)
|
||||
byteArrays.push(byteArray)
|
||||
}
|
||||
|
||||
return new Blob(byteArrays, { type })
|
||||
}
|
||||
|
||||
const imageCopy = useCallback(() => {
|
||||
const shareImage = async () => {
|
||||
try {
|
||||
const base64Data = url.split(',')[1]
|
||||
const blob = imageBase64ToBlob(base64Data, 'image/png')
|
||||
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({
|
||||
[blob.type]: blob,
|
||||
}),
|
||||
])
|
||||
setIsCopied(true)
|
||||
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: t('common.operation.imageCopied'),
|
||||
})
|
||||
}
|
||||
catch (err) {
|
||||
console.error('Failed to copy image:', err)
|
||||
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `${title}.png`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
|
||||
Toast.notify({
|
||||
type: 'info',
|
||||
message: t('common.operation.imageDownloaded'),
|
||||
})
|
||||
}
|
||||
}
|
||||
shareImage()
|
||||
}, [title, url])
|
||||
|
||||
const handleWheel = useCallback((e: React.WheelEvent<HTMLDivElement>) => {
|
||||
if (e.deltaY < 0)
|
||||
zoomIn()
|
||||
else
|
||||
zoomOut()
|
||||
}, [])
|
||||
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (scale > 1) {
|
||||
setIsDragging(true)
|
||||
dragStartRef.current = { x: e.clientX - position.x, y: e.clientY - position.y }
|
||||
}
|
||||
}, [scale, position])
|
||||
|
||||
const handleMouseMove = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (isDragging && scale > 1) {
|
||||
const deltaX = e.clientX - dragStartRef.current.x
|
||||
const deltaY = e.clientY - dragStartRef.current.y
|
||||
|
||||
// Calculate boundaries
|
||||
const imgRect = imgRef.current?.getBoundingClientRect()
|
||||
const containerRect = imgRef.current?.parentElement?.getBoundingClientRect()
|
||||
|
||||
if (imgRect && containerRect) {
|
||||
const maxX = (imgRect.width * scale - containerRect.width) / 2
|
||||
const maxY = (imgRect.height * scale - containerRect.height) / 2
|
||||
|
||||
setPosition({
|
||||
x: Math.max(-maxX, Math.min(maxX, deltaX)),
|
||||
y: Math.max(-maxY, Math.min(maxY, deltaY)),
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [isDragging, scale])
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
setIsDragging(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
return () => {
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
}, [handleMouseUp])
|
||||
|
||||
useHotkeys('esc', onCancel)
|
||||
useHotkeys('up', zoomIn)
|
||||
useHotkeys('down', zoomOut)
|
||||
useHotkeys('left', onPrev || noop)
|
||||
useHotkeys('right', onNext || noop)
|
||||
|
||||
return createPortal(
|
||||
<div className='image-preview-container fixed inset-0 z-[1000] flex items-center justify-center bg-black/80 p-8'
|
||||
onClick={e => e.stopPropagation()}
|
||||
onWheel={handleWheel}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
style={{ cursor: scale > 1 ? 'move' : 'default' }}
|
||||
tabIndex={-1}>
|
||||
{ }
|
||||
<img
|
||||
ref={imgRef}
|
||||
alt={title}
|
||||
src={isBase64(url) ? `data:image/png;base64,${url}` : url}
|
||||
className='max-h-full max-w-full'
|
||||
style={{
|
||||
transform: `scale(${scale}) translate(${position.x}px, ${position.y}px)`,
|
||||
transition: isDragging ? 'none' : 'transform 0.2s ease-in-out',
|
||||
}}
|
||||
/>
|
||||
<Tooltip popupContent={t('common.operation.copyImage')}>
|
||||
<div className='absolute right-48 top-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg'
|
||||
onClick={imageCopy}>
|
||||
{isCopied
|
||||
? <RiFileCopyLine className='h-4 w-4 text-green-500' />
|
||||
: <RiFileCopyLine className='h-4 w-4 text-gray-500' />}
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip popupContent={t('common.operation.zoomOut')}>
|
||||
<div className='absolute right-40 top-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg'
|
||||
onClick={zoomOut}>
|
||||
<RiZoomOutLine className='h-4 w-4 text-gray-500' />
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip popupContent={t('common.operation.zoomIn')}>
|
||||
<div className='absolute right-32 top-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg'
|
||||
onClick={zoomIn}>
|
||||
<RiZoomInLine className='h-4 w-4 text-gray-500' />
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip popupContent={t('common.operation.download')}>
|
||||
<div className='absolute right-24 top-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg'
|
||||
onClick={downloadImage}>
|
||||
<RiDownloadCloud2Line className='h-4 w-4 text-gray-500' />
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip popupContent={t('common.operation.openInNewTab')}>
|
||||
<div className='absolute right-16 top-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg'
|
||||
onClick={openInNewTab}>
|
||||
<RiAddBoxLine className='h-4 w-4 text-gray-500' />
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip popupContent={t('common.operation.cancel')}>
|
||||
<div
|
||||
className='absolute right-6 top-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-white/8 backdrop-blur-[2px]'
|
||||
onClick={onCancel}>
|
||||
<RiCloseLine className='h-4 w-4 text-gray-500' />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
export default ImagePreview
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { FC } from 'react'
|
||||
import {
|
||||
Fragment,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Uploader from './uploader'
|
||||
import ImageLinkInput from './image-link-input'
|
||||
import ImageList from './image-list'
|
||||
import { useImageFiles } from './hooks'
|
||||
import { ImagePlus } from '@/app/components/base/icons/src/vender/line/images'
|
||||
import { Link03 } from '@/app/components/base/icons/src/vender/line/general'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import type { ImageFile, VisionSettings } from '@/types/app'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
|
||||
type PasteImageLinkButtonProps = {
|
||||
onUpload: (imageFile: ImageFile) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
const PasteImageLinkButton: FC<PasteImageLinkButtonProps> = ({
|
||||
onUpload,
|
||||
disabled,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const handleUpload = (imageFile: ImageFile) => {
|
||||
setOpen(false)
|
||||
onUpload(imageFile)
|
||||
}
|
||||
|
||||
const handleToggle = () => {
|
||||
if (disabled)
|
||||
return
|
||||
|
||||
setOpen(v => !v)
|
||||
}
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement='top-start'
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={handleToggle}>
|
||||
<div className={`
|
||||
relative flex h-8 items-center justify-center rounded-lg bg-components-button-tertiary-bg px-3 text-xs text-text-tertiary hover:bg-components-button-tertiary-bg-hover
|
||||
${disabled ? 'cursor-not-allowed' : 'cursor-pointer'}
|
||||
`}>
|
||||
<Link03 className='mr-2 h-4 w-4' />
|
||||
{t('common.imageUploader.pasteImageLink')}
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className='z-10'>
|
||||
<div className='w-[320px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg p-2 shadow-lg'>
|
||||
<ImageLinkInput onUpload={handleUpload} />
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
|
||||
type TextGenerationImageUploaderProps = {
|
||||
settings: VisionSettings
|
||||
onFilesChange: (files: ImageFile[]) => void
|
||||
}
|
||||
const TextGenerationImageUploader: FC<TextGenerationImageUploaderProps> = ({
|
||||
settings,
|
||||
onFilesChange,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const {
|
||||
files,
|
||||
onUpload,
|
||||
onRemove,
|
||||
onImageLinkLoadError,
|
||||
onImageLinkLoadSuccess,
|
||||
onReUpload,
|
||||
} = useImageFiles()
|
||||
|
||||
useEffect(() => {
|
||||
onFilesChange(files)
|
||||
}, [files])
|
||||
|
||||
const localUpload = (
|
||||
<Uploader
|
||||
onUpload={onUpload}
|
||||
disabled={files.length >= settings.number_limits}
|
||||
limit={+settings.image_file_size_limit!}
|
||||
>
|
||||
{
|
||||
hovering => (
|
||||
<div className={`
|
||||
flex h-8 cursor-pointer items-center justify-center rounded-lg
|
||||
bg-components-button-tertiary-bg px-3 text-xs text-text-tertiary
|
||||
${hovering && 'hover:bg-components-button-tertiary-bg-hover'}
|
||||
`}>
|
||||
<ImagePlus className='mr-2 h-4 w-4' />
|
||||
{t('common.imageUploader.uploadFromComputer')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</Uploader>
|
||||
)
|
||||
|
||||
const urlUpload = (
|
||||
<PasteImageLinkButton
|
||||
onUpload={onUpload}
|
||||
disabled={files.length >= settings.number_limits}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className='mb-1'>
|
||||
<ImageList
|
||||
list={files}
|
||||
onRemove={onRemove}
|
||||
onReUpload={onReUpload}
|
||||
onImageLinkLoadError={onImageLinkLoadError}
|
||||
onImageLinkLoadSuccess={onImageLinkLoadSuccess}
|
||||
/>
|
||||
</div>
|
||||
<div className={`grid gap-1 ${settings.transfer_methods.length === 2 ? 'grid-cols-2' : 'grid-cols-1'}`}>
|
||||
{
|
||||
settings.transfer_methods.map((method) => {
|
||||
if (method === TransferMethod.local_file)
|
||||
return <Fragment key={TransferMethod.local_file}>{localUpload}</Fragment>
|
||||
|
||||
if (method === TransferMethod.remote_url)
|
||||
return <Fragment key={TransferMethod.remote_url}>{urlUpload}</Fragment>
|
||||
|
||||
return null
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TextGenerationImageUploader
|
||||
58
dify/web/app/components/base/image-uploader/uploader.tsx
Normal file
58
dify/web/app/components/base/image-uploader/uploader.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import type { ChangeEvent, FC } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useLocalFileUploader } from './hooks'
|
||||
import type { ImageFile } from '@/types/app'
|
||||
import { ALLOW_FILE_EXTENSIONS } from '@/types/app'
|
||||
|
||||
type UploaderProps = {
|
||||
children: (hovering: boolean) => React.JSX.Element
|
||||
onUpload: (imageFile: ImageFile) => void
|
||||
closePopover?: () => void
|
||||
limit?: number
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const Uploader: FC<UploaderProps> = ({
|
||||
children,
|
||||
onUpload,
|
||||
closePopover,
|
||||
limit,
|
||||
disabled,
|
||||
}) => {
|
||||
const [hovering, setHovering] = useState(false)
|
||||
const { handleLocalFileUpload } = useLocalFileUploader({
|
||||
limit,
|
||||
onUpload,
|
||||
disabled,
|
||||
})
|
||||
|
||||
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
|
||||
if (!file)
|
||||
return
|
||||
|
||||
handleLocalFileUpload(file)
|
||||
closePopover?.()
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className='relative'
|
||||
onMouseEnter={() => setHovering(true)}
|
||||
onMouseLeave={() => setHovering(false)}
|
||||
>
|
||||
{children(hovering)}
|
||||
<input
|
||||
className='absolute inset-0 block w-full cursor-pointer text-[0] opacity-0 disabled:cursor-not-allowed'
|
||||
onClick={e => ((e.target as HTMLInputElement).value = '')}
|
||||
type='file'
|
||||
accept={ALLOW_FILE_EXTENSIONS.map(ext => `.${ext}`).join(',')}
|
||||
onChange={handleChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Uploader
|
||||
55
dify/web/app/components/base/image-uploader/utils.ts
Normal file
55
dify/web/app/components/base/image-uploader/utils.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { upload } from '@/service/base'
|
||||
|
||||
/**
|
||||
* Get appropriate error message for image upload errors
|
||||
* @param error - The error object from upload failure
|
||||
* @param defaultMessage - Default error message to use if no specific error is matched
|
||||
* @param t - Translation function
|
||||
* @returns Localized error message
|
||||
*/
|
||||
export const getImageUploadErrorMessage = (error: any, defaultMessage: string, t: (key: string) => string): string => {
|
||||
const errorCode = error?.response?.code
|
||||
|
||||
if (errorCode === 'forbidden')
|
||||
return error?.response?.message
|
||||
|
||||
if (errorCode === 'file_extension_blocked')
|
||||
return t('common.fileUploader.fileExtensionBlocked')
|
||||
|
||||
return defaultMessage
|
||||
}
|
||||
|
||||
type ImageUploadParams = {
|
||||
file: File
|
||||
onProgressCallback: (progress: number) => void
|
||||
onSuccessCallback: (res: { id: string }) => void
|
||||
onErrorCallback: (error?: any) => void
|
||||
}
|
||||
type ImageUpload = (v: ImageUploadParams, isPublic?: boolean, url?: string) => void
|
||||
export const imageUpload: ImageUpload = ({
|
||||
file,
|
||||
onProgressCallback,
|
||||
onSuccessCallback,
|
||||
onErrorCallback,
|
||||
}, isPublic, url) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
const onProgress = (e: ProgressEvent) => {
|
||||
if (e.lengthComputable) {
|
||||
const percent = Math.floor(e.loaded / e.total * 100)
|
||||
onProgressCallback(percent)
|
||||
}
|
||||
}
|
||||
|
||||
upload({
|
||||
xhr: new XMLHttpRequest(),
|
||||
data: formData,
|
||||
onprogress: onProgress,
|
||||
}, isPublic, url)
|
||||
.then((res: { id: string }) => {
|
||||
onSuccessCallback(res)
|
||||
})
|
||||
.catch((error) => {
|
||||
onErrorCallback(error)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { FC } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
|
||||
type VideoPreviewProps = {
|
||||
url: string
|
||||
title: string
|
||||
onCancel: () => void
|
||||
}
|
||||
const VideoPreview: FC<VideoPreviewProps> = ({
|
||||
url,
|
||||
title,
|
||||
onCancel,
|
||||
}) => {
|
||||
return createPortal(
|
||||
<div className='fixed inset-0 z-[1000] flex items-center justify-center bg-black/80 p-8' onClick={e => e.stopPropagation()}>
|
||||
<div>
|
||||
<video controls title={title} autoPlay={false} preload="metadata">
|
||||
<source
|
||||
type="video/mp4"
|
||||
src={url}
|
||||
className='max-h-full max-w-full'
|
||||
/>
|
||||
</video>
|
||||
</div>
|
||||
<div
|
||||
className='absolute right-6 top-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-white/[0.08] backdrop-blur-[2px]'
|
||||
onClick={onCancel}
|
||||
>
|
||||
<RiCloseLine className='h-4 w-4 text-gray-500'/>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
export default VideoPreview
|
||||
Reference in New Issue
Block a user