fix: normalize app icon picker dialog state (#36621)

This commit is contained in:
yyh
2026-05-25 18:39:52 +08:00
committed by GitHub
parent b1f0a11d84
commit fe86fa31ec
39 changed files with 616 additions and 918 deletions

View File

@ -125,11 +125,11 @@ describe('AppIconPicker', () => {
const renderPicker = (props: Partial<ComponentProps<typeof AppIconPicker>> = {}) => {
const onSelect = vi.fn()
const onClose = vi.fn()
const onOpenChange = vi.fn()
const { container } = render(<AppIconPicker onSelect={onSelect} onClose={onClose} {...props} />)
const { container } = render(<AppIconPicker open onOpenChange={onOpenChange} onSelect={onSelect} {...props} />)
return { onSelect, onClose, container }
return { onSelect, onOpenChange, container }
}
beforeEach(() => {
@ -157,8 +157,9 @@ describe('AppIconPicker', () => {
it('should render emoji and image tabs when upload is enabled', async () => {
renderPicker()
expect(await screen.findByText(/emoji/i))!.toBeInTheDocument()
expect(screen.getByText(/image/i))!.toBeInTheDocument()
expect(screen.getByRole('dialog', { name: /emoji/i })).toBeInTheDocument()
expect(await screen.findByRole('button', { name: /emoji/i }))!.toBeInTheDocument()
expect(screen.getByRole('button', { name: /image/i }))!.toBeInTheDocument()
expect(screen.getByText(/cancel/i))!.toBeInTheDocument()
expect(screen.getByText(/ok/i))!.toBeInTheDocument()
})
@ -173,12 +174,12 @@ describe('AppIconPicker', () => {
})
describe('User Interactions', () => {
it('should call onClose when cancel is clicked', async () => {
const { onClose } = renderPicker()
it('should close when cancel is clicked', async () => {
const { onOpenChange } = renderPicker()
await userEvent.click(screen.getByText(/cancel/i))
expect(onClose).toHaveBeenCalledTimes(1)
expect(onOpenChange).toHaveBeenCalledWith(false)
})
it('should switch between emoji and image tabs', async () => {
@ -187,7 +188,7 @@ describe('AppIconPicker', () => {
await userEvent.click(screen.getByText(/image/i))
expect(screen.getByText(/drop.*here/i))!.toBeInTheDocument()
await userEvent.click(screen.getByText(/emoji/i))
await userEvent.click(screen.getByRole('button', { name: /emoji/i }))
expect(screen.getByPlaceholderText(/search/i))!.toBeInTheDocument()
})
@ -214,6 +215,14 @@ describe('AppIconPicker', () => {
})
})
it('should close through the dialog open change contract when Escape is pressed', async () => {
const { onOpenChange } = renderPicker()
await userEvent.keyboard('{Escape}')
expect(onOpenChange).toHaveBeenCalledWith(false, expect.anything())
})
it('should not call onSelect when no emoji has been selected', async () => {
const { onSelect } = renderPicker()

View File

@ -48,20 +48,20 @@ const AppIconPickerDemo = () => {
</pre>
</div>
{open && (
<AppIconPicker
onSelect={(result) => {
setSelection(result)
setOpen(false)
}}
onClose={() => setOpen(false)}
/>
)}
<AppIconPicker
open={open}
onOpenChange={setOpen}
onSelect={setSelection}
/>
</div>
)
}
export const Playground: Story = {
args: {
open: false,
onOpenChange: () => {},
},
render: () => <AppIconPickerDemo />,
parameters: {
docs: {
@ -74,15 +74,11 @@ const [selection, setSelection] = useState<AppIconSelection | null>(null)
return (
<>
<button onClick={() => setOpen(true)}>Choose icon…</button>
{open && (
<AppIconPicker
onSelect={(result) => {
setSelection(result)
setOpen(false)
}}
onClose={() => setOpen(false)}
/>
)}
<AppIconPicker
open={open}
onOpenChange={setOpen}
onSelect={setSelection}
/>
</>
)
`.trim(),

View File

@ -1,15 +1,15 @@
import type { FC } from 'react'
import type { Area } from 'react-easy-crop'
import type { OnImageInput } from './ImageInput'
import type { AppIconType, ImageFile } from '@/types/app'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import { RiImageCircleAiLine } from '@remixicon/react'
import { useCallback, useState } from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { DISABLE_UPLOAD_IMAGE_AS_ICON } from '@/config'
import Divider from '../divider'
import { defaultEmojiBackground } from '../emoji-picker/constants'
import EmojiPickerInner from '../emoji-picker/Inner'
import { useLocalFileUploader } from '../image-uploader/hooks'
import ImageInput from './ImageInput'
@ -31,8 +31,10 @@ export type AppIconImageSelection = {
export type AppIconSelection = AppIconEmojiSelection | AppIconImageSelection
type AppIconPickerProps = {
open: boolean
onOpenChange: (open: boolean) => void
onSelect?: (payload: AppIconSelection) => void
onClose?: () => void
enableImageUpload?: boolean
initialEmoji?: {
icon: string
background?: string | null
@ -40,11 +42,50 @@ type AppIconPickerProps = {
className?: string
}
const AppIconPicker: FC<AppIconPickerProps> = ({
function AppIconPicker({
open,
onOpenChange,
onSelect,
onClose,
enableImageUpload = true,
initialEmoji,
}) => {
className,
}: AppIconPickerProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
{open
? (
<AppIconPickerContent
key={`${initialEmoji?.icon ?? ''}:${initialEmoji?.background ?? ''}`}
initialEmoji={initialEmoji}
enableImageUpload={enableImageUpload}
className={className}
onOpenChange={onOpenChange}
onSelect={onSelect}
/>
)
: null}
</Dialog>
)
}
type AppIconPickerContentProps = {
className?: string
initialEmoji?: {
icon: string
background?: string | null
}
enableImageUpload: boolean
onOpenChange: (open: boolean) => void
onSelect?: (payload: AppIconSelection) => void
}
function AppIconPickerContent({
className,
initialEmoji,
enableImageUpload,
onOpenChange,
onSelect,
}: AppIconPickerContentProps) {
const { t } = useTranslation()
const tabs = [
@ -52,11 +93,17 @@ const AppIconPicker: FC<AppIconPickerProps> = ({
{ key: 'image', label: t('iconPicker.image', { ns: 'app' }), icon: <RiImageCircleAiLine className="size-4" /> },
]
const [activeTab, setActiveTab] = useState<AppIconType>('emoji')
const showImageUpload = enableImageUpload && !DISABLE_UPLOAD_IMAGE_AS_ICON
const [emoji, setEmoji] = useState<{ emoji: string, background: string }>()
const handleSelectEmoji = useCallback((emoji: string, background: string) => {
setEmoji({ emoji, background })
}, [setEmoji])
const [emoji, setEmoji] = useState<{ emoji: string, background: string } | undefined>(() => {
if (!initialEmoji?.icon)
return undefined
return {
emoji: initialEmoji.icon,
background: initialEmoji.background ?? defaultEmojiBackground,
}
})
const [uploading, setUploading] = useState<boolean>()
@ -71,6 +118,7 @@ const AppIconPicker: FC<AppIconPickerProps> = ({
fileId: imageFile.fileId,
url: imageFile.url,
})
onOpenChange(false)
}
},
})
@ -94,6 +142,7 @@ const AppIconPicker: FC<AppIconPickerProps> = ({
icon: emoji.emoji,
background: emoji.background,
})
onOpenChange(false)
}
}
else {
@ -111,54 +160,55 @@ const AppIconPicker: FC<AppIconPickerProps> = ({
}
return (
<Dialog open>
<DialogContent className={cn('w-full overflow-hidden! border-none text-left align-middle', s.container, 'h-[min(462px,calc(100dvh-2rem))]! max-h-none! w-[362px]! p-0!')}>
<DialogContent className={cn('w-full overflow-hidden! border-none text-left align-middle', s.container, 'h-[min(462px,calc(100dvh-2rem))]! max-h-none! w-[362px]! p-0!', className)}>
<DialogTitle className="sr-only">
{t('iconPicker.emoji', { ns: 'app' })}
</DialogTitle>
{!DISABLE_UPLOAD_IMAGE_AS_ICON && (
<div className="w-full p-2 pb-0">
<div className="flex items-center justify-center gap-2 rounded-xl bg-background-body p-1 text-text-primary">
{tabs.map(tab => (
<button
type="button"
key={tab.key}
className={cn(
'flex h-8 flex-1 shrink-0 items-center justify-center rounded-lg p-2 system-sm-medium text-text-tertiary',
activeTab === tab.key && 'bg-components-main-nav-nav-button-bg-active text-text-accent shadow-md',
)}
onClick={() => setActiveTab(tab.key as AppIconType)}
>
{tab.icon}
{' '}
{showImageUpload && (
<div className="w-full p-2 pb-0">
<div className="flex items-center justify-center gap-2 rounded-xl bg-background-body p-1 text-text-primary">
{tabs.map(tab => (
<button
type="button"
key={tab.key}
className={cn(
'flex h-8 flex-1 shrink-0 items-center justify-center rounded-lg p-2 system-sm-medium text-text-tertiary',
activeTab === tab.key && 'bg-components-main-nav-nav-button-bg-active text-text-accent shadow-md',
)}
onClick={() => setActiveTab(tab.key as AppIconType)}
>
{tab.icon}
{' '}
&nbsp;
{tab.label}
</button>
))}
</div>
{tab.label}
</button>
))}
</div>
)}
{activeTab === 'emoji' && (
<EmojiPickerInner
className={cn('flex-1 overflow-hidden pt-2')}
emoji={initialEmoji?.icon}
background={initialEmoji?.background ?? undefined}
onSelect={handleSelectEmoji}
/>
)}
{activeTab === 'image' && <ImageInput className={cn('flex-1 overflow-hidden')} onImageInput={handleImageInput} />}
<Divider className="m-0" />
<div className="flex w-full items-center justify-center gap-2 p-3">
<Button className="w-full" onClick={() => onClose?.()}>
{t('iconPicker.cancel', { ns: 'app' })}
</Button>
<Button variant="primary" className="w-full" disabled={uploading} loading={uploading} onClick={handleSelect}>
{t('iconPicker.ok', { ns: 'app' })}
</Button>
</div>
</DialogContent>
</Dialog>
)}
{activeTab === 'emoji' && (
<EmojiPickerInner
className={cn('flex-1 overflow-hidden pt-2')}
emoji={initialEmoji?.icon}
background={initialEmoji?.background ?? undefined}
onSelect={(emoji, background) => setEmoji({ emoji, background })}
/>
)}
{activeTab === 'image' && <ImageInput className={cn('flex-1 overflow-hidden')} onImageInput={handleImageInput} />}
<Divider className="m-0" />
<div className="flex w-full items-center justify-center gap-2 p-3">
<Button className="w-full" onClick={() => onOpenChange(false)}>
{t('iconPicker.cancel', { ns: 'app' })}
</Button>
<Button variant="primary" className="w-full" disabled={uploading} loading={uploading} onClick={handleSelect}>
{t('iconPicker.ok', { ns: 'app' })}
</Button>
</div>
</DialogContent>
)
}

View File

@ -1,6 +1,6 @@
'use client'
import type { EmojiMartData } from '@emoji-mart/data'
import type { ChangeEvent, FC } from 'react'
import type { ChangeEvent } from 'react'
import data from '@emoji-mart/data'
import {
MagnifyingGlassIcon,
@ -12,31 +12,10 @@ import { useState } from 'react'
import Divider from '@/app/components/base/divider'
import Input from '@/app/components/base/input'
import { searchEmoji } from '@/utils/emoji'
import { backgroundColors, defaultEmojiBackground } from './constants'
init({ data })
const backgroundColors = [
'#FFEAD5',
'#E4FBCC',
'#D3F8DF',
'#E0F2FE',
'#E0EAFF',
'#EFF1F5',
'#FBE8FF',
'#FCE7F6',
'#FEF7C3',
'#E6F4D7',
'#D5F5F6',
'#D1E9FF',
'#D1E0FF',
'#D5D9EB',
'#ECE9FE',
'#FFE4E8',
]
type IEmojiPickerInnerProps = {
emoji?: string
background?: string
@ -44,28 +23,32 @@ type IEmojiPickerInnerProps = {
className?: string
}
const EmojiPickerInner: FC<IEmojiPickerInnerProps> = ({
function EmojiPickerInner({
emoji,
background,
onSelect,
className,
}) => {
}: IEmojiPickerInnerProps) {
const { categories } = data as EmojiMartData
const [selectedEmoji, setSelectedEmoji] = useState(emoji || '')
const [selectedBackground, setSelectedBackground] = useState(background || backgroundColors[0])
const [selectedBackground, setSelectedBackground] = useState(background || defaultEmojiBackground)
const [showStyleColors, setShowStyleColors] = useState(!!emoji)
const [searchedEmojis, setSearchedEmojis] = useState<string[]>([])
const [isSearching, setIsSearching] = useState(false)
const styleColorsLabelId = React.useId()
React.useEffect(() => {
if (selectedEmoji) {
/* v8 ignore next 2 - @preserve */
if (selectedBackground)
onSelect?.(selectedEmoji, selectedBackground)
}
}, [onSelect, selectedEmoji, selectedBackground])
const handleEmojiSelect = (emoji: string) => {
setSelectedEmoji(emoji)
setShowStyleColors(true)
onSelect?.(emoji, selectedBackground)
}
const handleBackgroundSelect = (background: string) => {
setSelectedBackground(background)
if (selectedEmoji)
onSelect?.(selectedEmoji, background)
}
return (
<div className={cn(className, 'flex flex-col')}>
@ -108,8 +91,7 @@ const EmojiPickerInner: FC<IEmojiPickerInnerProps> = ({
aria-label={emoji}
className="inline-flex size-10 items-center justify-center rounded-lg border-none bg-transparent p-0"
onClick={() => {
setSelectedEmoji(emoji)
setShowStyleColors(true)
handleEmojiSelect(emoji)
}}
>
<span className="flex size-8 cursor-pointer items-center justify-center rounded-lg p-1 ring-components-input-border-hover ring-offset-1 hover:ring-1">
@ -136,8 +118,7 @@ const EmojiPickerInner: FC<IEmojiPickerInnerProps> = ({
aria-label={emoji}
className="inline-flex size-10 items-center justify-center rounded-lg border-none bg-transparent p-0"
onClick={() => {
setSelectedEmoji(emoji)
setShowStyleColors(true)
handleEmojiSelect(emoji)
}}
>
<span className="flex size-8 cursor-pointer items-center justify-center rounded-lg p-1 ring-components-input-border-hover ring-offset-1 hover:ring-1">
@ -194,7 +175,7 @@ const EmojiPickerInner: FC<IEmojiPickerInnerProps> = ({
)
}
onClick={() => {
setSelectedBackground(color)
handleBackgroundSelect(color)
}}
>
<span

View File

@ -46,13 +46,11 @@ describe('EmojiPickerInner', () => {
expect(screen.getByPlaceholderText('Search emojis...'))!.toBeInTheDocument()
})
it('initializes selected emoji and background when provided', async () => {
it('initializes selected emoji and background when provided', () => {
render(<EmojiPickerInner emoji="rabbit" background="#E4FBCC" onSelect={mockOnSelect} />)
expect(screen.getByText('Choose Style'))!.toBeInTheDocument()
await waitFor(() => {
expect(mockOnSelect).toHaveBeenCalledWith('rabbit', '#E4FBCC')
})
expect(mockOnSelect).not.toHaveBeenCalled()
})
})

View File

@ -26,26 +26,27 @@ vi.mock('@/utils/emoji', () => ({
describe('EmojiPicker', () => {
const mockOnSelect = vi.fn()
const mockOnClose = vi.fn()
const mockOnOpenChange = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('renders nothing when isModal is false', () => {
it('renders nothing when closed', () => {
const { container } = render(
<EmojiPicker isModal={false} />,
<EmojiPicker open={false} onOpenChange={mockOnOpenChange} />,
)
expect(container.firstChild).toBeNull()
})
it('renders modal when isModal is true', async () => {
it('renders modal when open', async () => {
await act(async () => {
render(
<EmojiPicker isModal={true} />,
<EmojiPicker open onOpenChange={mockOnOpenChange} />,
)
})
expect(screen.getByRole('dialog', { name: /Emoji/i }))!.toBeInTheDocument()
expect(screen.getByPlaceholderText('Search emojis...'))!.toBeInTheDocument()
expect(screen.getByText(/Cancel/i))!.toBeInTheDocument()
expect(screen.getByText(/OK/i))!.toBeInTheDocument()
@ -54,7 +55,7 @@ describe('EmojiPicker', () => {
it('OK button is disabled initially', async () => {
await act(async () => {
render(
<EmojiPicker />,
<EmojiPicker open onOpenChange={mockOnOpenChange} />,
)
})
const okButton = screen.getByText(/OK/i).closest('button')
@ -65,7 +66,7 @@ describe('EmojiPicker', () => {
const customClass = 'custom-wrapper-class'
await act(async () => {
render(
<EmojiPicker className={customClass} />,
<EmojiPicker open onOpenChange={mockOnOpenChange} className={customClass} />,
)
})
const dialog = screen.getByRole('dialog')
@ -77,7 +78,7 @@ describe('EmojiPicker', () => {
it('calls onSelect with selected emoji and background when OK is clicked', async () => {
await act(async () => {
render(
<EmojiPicker onSelect={mockOnSelect} />,
<EmojiPicker open onOpenChange={mockOnOpenChange} onSelect={mockOnSelect} />,
)
})
@ -95,10 +96,10 @@ describe('EmojiPicker', () => {
expect(mockOnSelect).toHaveBeenCalledWith(expect.any(String), expect.any(String))
})
it('calls onClose when Cancel is clicked', async () => {
it('closes when Cancel is clicked', async () => {
await act(async () => {
render(
<EmojiPicker onClose={mockOnClose} />,
<EmojiPicker open onOpenChange={mockOnOpenChange} />,
)
})
@ -107,7 +108,7 @@ describe('EmojiPicker', () => {
fireEvent.click(cancelButton)
})
expect(mockOnClose).toHaveBeenCalled()
expect(mockOnOpenChange).toHaveBeenCalledWith(false)
})
})
})

View File

@ -0,0 +1,23 @@
export const backgroundColors = [
'#FFEAD5',
'#E4FBCC',
'#D3F8DF',
'#E0F2FE',
'#E0EAFF',
'#EFF1F5',
'#FBE8FF',
'#FCE7F6',
'#FEF7C3',
'#E6F4D7',
'#D5F5F6',
'#D1E9FF',
'#D1E0FF',
'#D5D9EB',
'#ECE9FE',
'#FFE4E8',
]
export const defaultEmojiBackground = backgroundColors[0]!

View File

@ -47,20 +47,20 @@ const EmojiPickerDemo = () => {
</pre>
</div>
{open && (
<EmojiPicker
onSelect={(emoji, background) => {
setSelection({ emoji, background })
setOpen(false)
}}
onClose={() => setOpen(false)}
/>
)}
<EmojiPicker
open={open}
onOpenChange={setOpen}
onSelect={(emoji, background) => setSelection({ emoji, background })}
/>
</div>
)
}
export const Playground: Story = {
args: {
open: false,
onOpenChange: () => {},
},
render: () => <EmojiPickerDemo />,
parameters: {
docs: {
@ -73,15 +73,11 @@ const [selection, setSelection] = useState<{ emoji: string; background: string }
return (
<>
<button onClick={() => setOpen(true)}>Open emoji picker…</button>
{open && (
<EmojiPicker
onSelect={(emoji, background) => {
setSelection({ emoji, background })
setOpen(false)
}}
onClose={() => setOpen(false)}
/>
)}
<EmojiPicker
open={open}
onOpenChange={setOpen}
onSelect={(emoji, background) => setSelection({ emoji, background })}
/>
</>
)
`.trim(),

View File

@ -1,75 +1,95 @@
'use client'
import type { FC } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
import * as React from 'react'
import { useCallback, useState } from 'react'
import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
import EmojiPickerInner from './Inner'
type IEmojiPickerProps = {
isModal?: boolean
type EmojiPickerProps = {
open: boolean
onOpenChange: (open: boolean) => void
onSelect?: (emoji: string, background: string) => void
onClose?: () => void
className?: string
}
const EmojiPicker: FC<IEmojiPickerProps> = ({
isModal = true,
function EmojiPicker({
open,
onOpenChange,
onSelect,
onClose,
className,
}) => {
}: EmojiPickerProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
{open
? (
<EmojiPickerContent
className={className}
onOpenChange={onOpenChange}
onSelect={onSelect}
/>
)
: null}
</Dialog>
)
}
type EmojiPickerContentProps = {
className?: string
onOpenChange: (open: boolean) => void
onSelect?: (emoji: string, background: string) => void
}
function EmojiPickerContent({
className,
onOpenChange,
onSelect,
}: EmojiPickerContentProps) {
const { t } = useTranslation()
const [selectedEmoji, setSelectedEmoji] = useState('')
const [selectedBackground, setSelectedBackground] = useState<string>()
const handleSelectEmoji = useCallback((emoji: string, background: string) => {
setSelectedEmoji(emoji)
setSelectedBackground(background)
}, [setSelectedEmoji, setSelectedBackground])
return (
<DialogContent
className={cn(
'max-h-none w-full overflow-hidden! text-left align-middle',
'flex max-h-[552px] flex-col rounded-xl border-[0.5px] border-divider-subtle p-0 shadow-xl',
className,
)}
>
<DialogTitle className="sr-only">
{t('iconPicker.emoji', { ns: 'app' })}
</DialogTitle>
return isModal
? (
<Dialog open>
<DialogContent
className={cn(
'max-h-none w-full overflow-hidden! text-left align-middle',
'flex max-h-[552px] flex-col rounded-xl border-[0.5px] border-divider-subtle p-0 shadow-xl',
className,
)}
>
<EmojiPickerInner
className="pt-3"
onSelect={handleSelectEmoji}
/>
<Divider className="mt-3 mb-0" />
<div className="flex w-full items-center justify-center gap-2 p-3">
<Button
className="w-full"
onClick={() => {
onClose?.()
}}
>
{t('iconPicker.cancel', { ns: 'app' })}
</Button>
<Button
disabled={selectedEmoji === '' || !selectedBackground}
variant="primary"
className="w-full"
onClick={() => {
onSelect?.(selectedEmoji, selectedBackground!)
}}
>
{t('iconPicker.ok', { ns: 'app' })}
</Button>
</div>
</DialogContent>
</Dialog>
)
: <></>
<EmojiPickerInner
className="pt-3"
onSelect={(emoji, background) => {
setSelectedEmoji(emoji)
setSelectedBackground(background)
}}
/>
<Divider className="mt-3 mb-0" />
<div className="flex w-full items-center justify-center gap-2 p-3">
<Button
className="w-full"
onClick={() => onOpenChange(false)}
>
{t('iconPicker.cancel', { ns: 'app' })}
</Button>
<Button
disabled={selectedEmoji === '' || !selectedBackground}
variant="primary"
className="w-full"
onClick={() => {
onSelect?.(selectedEmoji, selectedBackground!)
onOpenChange(false)
}}
>
{t('iconPicker.ok', { ns: 'app' })}
</Button>
</div>
</DialogContent>
)
}
export default EmojiPicker

View File

@ -81,7 +81,7 @@ export const useImageFiles = () => {
filesRef.current = newFiles
setFiles(newFiles)
},
}, !!params.token)
}, !!params?.token)
}
}
const handleClear = () => {
@ -145,13 +145,13 @@ export const useLocalFileUploader = ({ limit, disabled = false, onUpload }: useL
toast.error(errorMessage)
onUpload({ ...imageFile, progress: -1 })
},
}, !!params.token)
}, !!params?.token)
}, false)
reader.addEventListener('error', () => {
toast.error(t('imageUploader.uploadFromComputerReadError', { ns: 'common' }))
}, false)
reader.readAsDataURL(file)
}, [disabled, limit, t, onUpload, params.token])
}, [disabled, limit, t, onUpload, params?.token])
return { disabled, handleLocalFileUpload }
}
type useClipboardUploaderProps = {