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.json

npm-зависимости

  • @vueuse/core

Источник: порт из ReUI (Keenthemes, MIT)