internals
Use file Upload
Composable use-file-upload() — VueUse-порт одноимённого хука ReUI.
Загрузка превью…
src/composables/use-file-upload.ts
/**
* Порт `useFileUpload` (registry-reui/bases/radix/hooks/use-file-upload.ts, MIT)
* поверх `useFileDialog` из `@vueuse/core` (см. docs/PORTING.md, раздел 2:
* "React-хуки общего назначения → VueUse").
*
* `useFileDialog` покрывает только открытие системного диалога выбора
* файлов и получение `FileList` — вся остальная логика оригинала
* (drag-and-drop реактивное состояние, валидация размера/типа, превью,
* дедупликация, ошибки) у VueUse аналога не имеет и перенесена вручную на
* `ref`, буква в букву за оригиналом (см. правило 4a).
*
* Сигнатура возврата сохранена как можно ближе к оригиналу: React-кортеж
* `[state, actions]` — здесь тоже кортеж `[state, actions]`, где `state`
* реактивен (`Ref<FileUploadState>`, читается через `state.value` или
* `reactive`-доступ к полям), а `actions` — тот же набор функций с теми же
* именами и сигнатурами (адаптированными под Vue: `ChangeEvent`/`DragEvent`
* React → нативные DOM `Event`/`DragEvent`).
*/
import { reactive, ref } from "vue"
import { useFileDialog } from "@vueuse/core"
export type FileMetadata = {
name: string
size: number
type: string
url: string
id: string
}
export type FileWithPreview = {
file: File | FileMetadata
id: string
preview?: string
}
export type FileUploadOptions = {
maxFiles?: number // Только для multiple, по умолчанию Infinity
maxSize?: number // в байтах
accept?: string
multiple?: boolean // По умолчанию false
initialFiles?: FileMetadata[]
onFilesChange?: (files: FileWithPreview[]) => void
onFilesAdded?: (addedFiles: FileWithPreview[]) => void
onError?: (errors: string[]) => void
}
export type FileUploadState = {
files: FileWithPreview[]
isDragging: boolean
errors: string[]
}
export type FileUploadActions = {
addFiles: (files: FileList | File[]) => void
removeFile: (id: string) => void
clearFiles: () => void
clearErrors: () => void
handleDragEnter: (e: DragEvent) => void
handleDragLeave: (e: DragEvent) => void
handleDragOver: (e: DragEvent) => void
handleDrop: (e: DragEvent) => void
handleFileChange: (e: Event) => void
openFileDialog: () => void
getInputProps: (props?: Record<string, unknown>) => Record<string, unknown>
}
export const useFileUpload = (options: FileUploadOptions = {}): [FileUploadState, FileUploadActions] => {
const {
maxFiles = Number.POSITIVE_INFINITY,
maxSize = Number.POSITIVE_INFINITY,
accept = "*",
multiple = false,
initialFiles = [],
onFilesChange,
onFilesAdded,
onError,
} = options
const state = reactive<FileUploadState>({
files: initialFiles.map((file) => ({
file,
id: file.id,
preview: file.url,
})),
isDragging: false,
errors: [],
})
const inputRef = ref<HTMLInputElement | null>(null)
const { open: openDialog, reset: resetDialog, onChange: onDialogChange } = useFileDialog({
accept,
multiple,
})
onDialogChange((fileList) => {
if (fileList && fileList.length > 0) {
addFiles(fileList)
}
})
const validateFile = (file: File | FileMetadata): string | null => {
if (file.size > maxSize) {
return `File "${file.name}" exceeds the maximum size of ${formatBytes(maxSize)}.`
}
if (accept !== "*") {
const acceptedTypes = accept.split(",").map((type) => type.trim())
const fileType = file instanceof File ? file.type || "" : file.type
const fileExtension = `.${file.name.split(".").pop()}`
const isAccepted = acceptedTypes.some((type) => {
if (type.startsWith(".")) {
return fileExtension.toLowerCase() === type.toLowerCase()
}
if (type.endsWith("/*")) {
const baseType = type.split("/")[0]
return fileType.startsWith(`${baseType}/`)
}
return fileType === type
})
if (!isAccepted) {
return `File "${file.name}" is not an accepted file type.`
}
}
return null
}
const createPreview = (file: File | FileMetadata): string | undefined => {
if (file instanceof File) {
return URL.createObjectURL(file)
}
return file.url
}
const generateUniqueId = (file: File | FileMetadata): string => {
if (file instanceof File) {
return `${file.name}-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
}
return file.id
}
const clearFiles = () => {
// Clean up object URLs
for (const file of state.files) {
if (file.preview && file.file instanceof File && file.file.type.startsWith("image/")) {
URL.revokeObjectURL(file.preview)
}
}
if (inputRef.value) {
inputRef.value.value = ""
}
resetDialog()
state.files = []
state.errors = []
onFilesChange?.(state.files)
}
const addFiles = (newFiles: FileList | File[]) => {
if (!newFiles || newFiles.length === 0) return
const newFilesArray = Array.from(newFiles)
const errors: string[] = []
// Clear existing errors when new files are uploaded
state.errors = []
// In single file mode, clear existing files first
if (!multiple) {
clearFiles()
}
// Check if adding these files would exceed maxFiles (only in multiple mode)
if (multiple && maxFiles !== Number.POSITIVE_INFINITY && state.files.length + newFilesArray.length > maxFiles) {
errors.push(`You can only upload a maximum of ${maxFiles} files.`)
onError?.(errors)
state.errors = errors
return
}
const validFiles: FileWithPreview[] = []
for (const file of newFilesArray) {
// Only check for duplicates if multiple files are allowed
if (multiple) {
const isDuplicate = state.files.some(
(existingFile) => existingFile.file.name === file.name && existingFile.file.size === file.size
)
// Skip duplicate files silently
if (isDuplicate) {
return
}
}
// Check file size
if (file.size > maxSize) {
errors.push(
multiple
? `Some files exceed the maximum size of ${formatBytes(maxSize)}.`
: `File exceeds the maximum size of ${formatBytes(maxSize)}.`
)
continue
}
const error = validateFile(file)
if (error) {
errors.push(error)
} else {
validFiles.push({
file,
id: generateUniqueId(file),
preview: createPreview(file),
})
}
}
// Only update state if we have valid files to add
if (validFiles.length > 0) {
// Call the onFilesAdded callback with the newly added valid files
onFilesAdded?.(validFiles)
const newFileList = !multiple ? validFiles : [...state.files, ...validFiles]
state.files = newFileList
state.errors = errors
onFilesChange?.(newFileList)
} else if (errors.length > 0) {
onError?.(errors)
state.errors = errors
}
// Reset input value after handling files
if (inputRef.value) {
inputRef.value.value = ""
}
}
const removeFile = (id: string) => {
const fileToRemove = state.files.find((file) => file.id === id)
if (fileToRemove && fileToRemove.preview && fileToRemove.file instanceof File && fileToRemove.file.type.startsWith("image/")) {
URL.revokeObjectURL(fileToRemove.preview)
}
const newFiles = state.files.filter((file) => file.id !== id)
state.files = newFiles
state.errors = []
onFilesChange?.(newFiles)
}
const clearErrors = () => {
state.errors = []
}
const handleDragEnter = (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
state.isDragging = true
}
const handleDragLeave = (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
if (e.currentTarget instanceof Node && e.relatedTarget instanceof Node && (e.currentTarget as Node).contains(e.relatedTarget)) {
return
}
state.isDragging = false
}
const handleDragOver = (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
}
const handleDrop = (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
state.isDragging = false
// Don't process files if the input is disabled
if (inputRef.value?.disabled) {
return
}
if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
// In single file mode, only use the first file
if (!multiple) {
const file = e.dataTransfer.files[0]
if (file) addFiles([file])
} else {
addFiles(e.dataTransfer.files)
}
}
}
const handleFileChange = (e: Event) => {
const target = e.target as HTMLInputElement
if (target.files && target.files.length > 0) {
addFiles(target.files)
}
}
const openFileDialog = () => {
if (inputRef.value) {
inputRef.value.click()
return
}
openDialog()
}
const getInputProps = (props: Record<string, unknown> = {}) => {
return {
...props,
type: "file" as const,
onChange: handleFileChange,
accept: (props.accept as string) || accept,
multiple: props.multiple !== undefined ? props.multiple : multiple,
ref: inputRef,
}
}
return [
state,
{
addFiles,
removeFile,
clearFiles,
clearErrors,
handleDragEnter,
handleDragLeave,
handleDragOver,
handleDrop,
handleFileChange,
openFileDialog,
getInputProps,
},
]
}
// Helper function to format bytes to human-readable format
export const formatBytes = (bytes: number, decimals = 2): string => {
if (bytes === 0) return "0 Bytes"
const k = 1024
const dm = decimals < 0 ? 0 : decimals
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]
const i = Math.floor(Math.log(bytes) / Math.log(k))
return Number.parseFloat((bytes / k ** i).toFixed(dm)) + (sizes[i] ?? "")
}
Установка
npx shadcn-vue@latest add https://revueui.rootapi.dev/r/use-file-upload.jsonnpm-зависимости
- @vueuse/core
Источник: порт из ReUI (Keenthemes, MIT)