Реализация Drag-and-Drop загрузки файлов на сайт

Наша компания занимается разработкой, поддержкой и обслуживанием сайтов любой сложности. От простых одностраничных сайтов до масштабных кластерных систем построенных на микро сервисах. Опыт разработчиков подтвержден сертификатами от вендоров.

Разработка и обслуживание любых видов сайтов:

Информационные сайты или веб-приложения
Сайты визитки, landing page, корпоративные сайты, онлайн каталоги, квиз, промо-сайты, блоги, новостные ресурсы, информационные порталы, форумы, агрегаторы
Сайты или веб-приложения электронной коммерции
Интернет-магазины, B2B-порталы, маркетплейсы, онлайн-обменники, кэшбэк-сайты, биржи, дропшиппинг-платформы, парсеры товаров
Веб-приложения для управления бизнес-процессами
CRM-системы, ERP-системы, корпоративные порталы, системы управления производством, парсеры информации
Сайты или веб-приложения электронных услуг
Доски объявлений, онлайн-школы, онлайн-кинотеатры, конструкторы сайтов, порталы предоставления электронных услуг, видеохостинги, тематические порталы

Это лишь некоторые из технических типов сайтов, с которыми мы работаем, и каждый из них может иметь свои специфические особенности и функциональность, а также быть адаптированным под конкретные потребности и цели клиента

Предлагаемые услуги
Показано 1 из 1 услугВсе 2065 услуг
Реализация Drag-and-Drop загрузки файлов на сайт
Средняя
от 1 рабочего дня до 3 рабочих дней
Часто задаваемые вопросы

Наши компетенции:

Этапы разработки

Последние работы

  • image_website-b2b-advance_0.png
    Разработка сайта компании B2B ADVANCE
    1262
  • image_web-applications_feedme_466_0.webp
    Разработка веб-приложения для компании FEEDME
    1171
  • image_websites_belfingroup_462_0.webp
    Разработка веб-сайта для компании БЕЛФИНГРУПП
    874
  • image_ecommerce_furnoro_435_0.webp
    Разработка интернет магазина для компании FURNORO
    1094
  • image_crm_enviok_479_0.webp
    Разработка веб-приложения для компании Enviok
    831
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Разработка веб-сайта для компании ФИКСПЕР
    851

Реализация Drag-and-Drop загрузки файлов на сайт

Drag-and-drop загрузка — перетаскивание файлов из файлового менеджера прямо в браузер. Уменьшает количество кликов и воспринимается как признак качественного интерфейса в задачах с частой загрузкой файлов: менеджеры документов, CMS, личные кабинеты с вложениями.

Браузерный Drag and Drop API

// hooks/useDragAndDrop.ts
import { useState, useCallback, DragEvent } from 'react'

interface UseDragAndDropOptions {
  onDrop: (files: File[]) => void
  accept?: string[] // MIME-типы: ['image/jpeg', 'image/png']
  disabled?: boolean
}

export function useDragAndDrop({ onDrop, accept, disabled }: UseDragAndDropOptions) {
  const [isDragOver, setIsDragOver] = useState(false)
  const [isDragActive, setIsDragActive] = useState(false)

  const handleDragEnter = useCallback((e: DragEvent) => {
    e.preventDefault()
    e.stopPropagation()
    if (disabled) return
    setIsDragActive(true)
    // Проверяем, что перетаскиваются файлы
    if (e.dataTransfer.items?.length > 0) {
      setIsDragOver(true)
    }
  }, [disabled])

  const handleDragLeave = useCallback((e: DragEvent) => {
    e.preventDefault()
    e.stopPropagation()
    // Проверяем, что ушли с зоны полностью (не на дочерний элемент)
    if (e.currentTarget.contains(e.relatedTarget as Node)) return
    setIsDragOver(false)
    setIsDragActive(false)
  }, [])

  const handleDragOver = useCallback((e: DragEvent) => {
    e.preventDefault()
    e.dataTransfer.dropEffect = 'copy'
  }, [])

  const handleDrop = useCallback((e: DragEvent) => {
    e.preventDefault()
    e.stopPropagation()
    setIsDragOver(false)
    setIsDragActive(false)

    if (disabled) return

    const droppedFiles = Array.from(e.dataTransfer.files)

    const filtered = accept
      ? droppedFiles.filter(f => accept.some(mime => {
          if (mime.endsWith('/*')) {
            return f.type.startsWith(mime.replace('/*', '/'))
          }
          return f.type === mime
        }))
      : droppedFiles

    if (filtered.length > 0) {
      onDrop(filtered)
    }
  }, [onDrop, accept, disabled])

  return {
    isDragOver,
    isDragActive,
    dropZoneProps: {
      onDragEnter: handleDragEnter,
      onDragLeave: handleDragLeave,
      onDragOver: handleDragOver,
      onDrop: handleDrop,
    },
  }
}

Компонент зоны сброса

// components/DropZone.tsx
import { useRef } from 'react'
import { useDragAndDrop } from '@/hooks/useDragAndDrop'
import { cn } from '@/lib/utils'

interface DropZoneProps {
  onFiles: (files: File[]) => void
  accept?: string[]
  maxFiles?: number
  disabled?: boolean
  children?: React.ReactNode
}

export function DropZone({ onFiles, accept, maxFiles, disabled, children }: DropZoneProps) {
  const inputRef = useRef<HTMLInputElement>(null)

  const { isDragOver, isDragActive, dropZoneProps } = useDragAndDrop({
    onDrop: onFiles,
    accept,
    disabled,
  })

  const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    if (e.target.files) {
      onFiles(Array.from(e.target.files).slice(0, maxFiles))
      e.target.value = '' // сбросить, чтобы можно было выбрать тот же файл снова
    }
  }

  return (
    <div
      {...dropZoneProps}
      onClick={() => !disabled && inputRef.current?.click()}
      role="button"
      tabIndex={disabled ? -1 : 0}
      aria-disabled={disabled}
      onKeyDown={e => e.key === 'Enter' && !disabled && inputRef.current?.click()}
      className={cn(
        'relative border-2 border-dashed rounded-lg p-8 text-center transition-colors cursor-pointer',
        'focus:outline-none focus:ring-2 focus:ring-primary',
        isDragOver && 'border-primary bg-primary/5',
        isDragActive && 'border-primary',
        !isDragOver && 'border-muted-foreground/30 hover:border-muted-foreground/60',
        disabled && 'opacity-50 cursor-not-allowed',
      )}
    >
      {/* Overlay при перетаскивании */}
      {isDragOver && (
        <div className="absolute inset-0 flex items-center justify-center rounded-lg bg-primary/10">
          <p className="text-lg font-medium text-primary">Отпустите для загрузки</p>
        </div>
      )}

      {children ?? (
        <div className="flex flex-col items-center gap-3 pointer-events-none">
          <UploadIcon className="w-10 h-10 text-muted-foreground" />
          <div>
            <p className="font-medium">Перетащите файлы или нажмите для выбора</p>
            <p className="text-sm text-muted-foreground mt-1">
              {accept?.join(', ') ?? 'Любые файлы'} · до {maxFiles ?? 10} файлов
            </p>
          </div>
        </div>
      )}

      <input
        ref={inputRef}
        type="file"
        multiple
        accept={accept?.join(',')}
        className="sr-only"
        onChange={handleInputChange}
        disabled={disabled}
        aria-label="Выбор файлов"
      />
    </div>
  )
}

Превью изображений до загрузки

// hooks/useFilePreview.ts
import { useState, useEffect } from 'react'

export function useFilePreview(file: File | null): string | null {
  const [preview, setPreview] = useState<string | null>(null)

  useEffect(() => {
    if (!file || !file.type.startsWith('image/')) {
      setPreview(null)
      return
    }

    const url = URL.createObjectURL(file)
    setPreview(url)

    return () => URL.revokeObjectURL(url) // освобождаем память
  }, [file])

  return preview
}
// Компонент карточки файла с превью
function FileCard({ uploadFile, onRemove }: { uploadFile: UploadFile; onRemove: () => void }) {
  const preview = useFilePreview(
    uploadFile.file.type.startsWith('image/') ? uploadFile.file : null
  )

  return (
    <div className="flex items-center gap-3 p-3 border rounded-lg">
      {preview ? (
        <img
          src={preview}
          alt={uploadFile.file.name}
          className="w-12 h-12 object-cover rounded"
        />
      ) : (
        <FileIcon mimeType={uploadFile.file.type} className="w-12 h-12" />
      )}

      <div className="flex-1 min-w-0">
        <p className="truncate text-sm font-medium">{uploadFile.file.name}</p>
        <p className="text-xs text-muted-foreground">
          {formatFileSize(uploadFile.file.size)}
        </p>
        {uploadFile.status === 'uploading' && (
          <div className="mt-1 w-full bg-gray-200 rounded-full h-1.5">
            <div
              className="bg-primary h-1.5 rounded-full transition-all duration-300"
              style={{ width: `${uploadFile.progress}%` }}
            />
          </div>
        )}
      </div>

      <button
        onClick={onRemove}
        disabled={uploadFile.status === 'uploading'}
        className="text-muted-foreground hover:text-destructive"
        aria-label={`Удалить ${uploadFile.file.name}`}
      >
        ✕
      </button>
    </div>
  )
}

function formatFileSize(bytes: number): string {
  if (bytes < 1024) return `${bytes} B`
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
  return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}

Drag-and-drop поверх всей страницы

Некоторые приложения (почтовые клиенты, файловые менеджеры) принимают файлы в любом месте страницы:

// hooks/useGlobalDrop.ts
import { useEffect, useState } from 'react'

export function useGlobalDrop(onDrop: (files: File[]) => void) {
  const [isActive, setIsActive] = useState(false)
  let dragCounter = 0

  useEffect(() => {
    const handleDragEnter = (e: DragEvent) => {
      if (!e.dataTransfer?.types.includes('Files')) return
      dragCounter++
      setIsActive(true)
    }

    const handleDragLeave = () => {
      dragCounter--
      if (dragCounter === 0) setIsActive(false)
    }

    const handleDrop = (e: DragEvent) => {
      e.preventDefault()
      dragCounter = 0
      setIsActive(false)
      if (e.dataTransfer?.files.length) {
        onDrop(Array.from(e.dataTransfer.files))
      }
    }

    const handleDragOver = (e: DragEvent) => e.preventDefault()

    document.addEventListener('dragenter', handleDragEnter)
    document.addEventListener('dragleave', handleDragLeave)
    document.addEventListener('dragover', handleDragOver)
    document.addEventListener('drop', handleDrop)

    return () => {
      document.removeEventListener('dragenter', handleDragEnter)
      document.removeEventListener('dragleave', handleDragLeave)
      document.removeEventListener('dragover', handleDragOver)
      document.removeEventListener('drop', handleDrop)
    }
  }, [onDrop])

  return isActive
}
function App() {
  const [files, setFiles] = useState<File[]>([])
  const isGlobalDragActive = useGlobalDrop(newFiles => setFiles(prev => [...prev, ...newFiles]))

  return (
    <div>
      {isGlobalDragActive && (
        <div className="fixed inset-0 z-50 bg-primary/20 border-4 border-dashed border-primary flex items-center justify-center pointer-events-none">
          <p className="text-2xl font-bold text-primary">Отпустите файлы</p>
        </div>
      )}
      {/* остальной UI */}
    </div>
  )
}

react-dropzone: готовая библиотека

Если нужно быстро, без написания хуков с нуля:

npm install react-dropzone
import { useDropzone } from 'react-dropzone'

function MediaUploader({ onFiles }: { onFiles: (files: File[]) => void }) {
  const { getRootProps, getInputProps, isDragActive, acceptedFiles } = useDropzone({
    onDrop: onFiles,
    accept: { 'image/*': ['.jpg', '.jpeg', '.png', '.webp'] },
    maxFiles: 20,
    maxSize: 10 * 1024 * 1024,
  })

  return (
    <div
      {...getRootProps()}
      className={cn('dropzone', isDragActive && 'dropzone--active')}
    >
      <input {...getInputProps()} />
      {isDragActive ? (
        <p>Отпустите файлы...</p>
      ) : (
        <p>Перетащите изображения или кликните для выбора</p>
      )}
    </div>
  )
}

Сроки

Кастомный хук + компонент DropZone с превью + индикатор прогресса — 1.5–2 дня. С глобальным drag-over оверлеем, сортировкой файлов, повторными попытками загрузки и интеграцией с presigned S3 URL — 3–4 дня.