Реализация 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 файлов из системы в десктоп-приложение

Перетаскивание файлов из файлового менеджера в окно приложения — базовый сценарий для большинства десктоп-продуктивных инструментов. В Electron это реализуется через стандартные drag-and-drop события браузера (renderer-процесс) плюс опциональную нативную обработку в main-процессе. В Tauri — через tauri-plugin-drag-drop. Разберём Electron с полной цепочкой: UI → renderer → IPC → main → файловая система.

Базовый обработчик в renderer

HTML5 Drag-and-Drop API работает в Electron renderer как в обычном браузере. Главное отличие: при перетаскивании файлов из ОС браузерное событие содержит event.dataTransfer.files — объект FileList с нативными File-объектами.

// src/hooks/useFileDrop.ts
import { useRef, useState, useCallback, DragEvent } from 'react'

export interface DroppedFile {
  name: string
  path: string      // абсолютный путь — доступен только в Electron
  size: number
  type: string
  lastModified: number
}

interface UseFileDropOptions {
  accept?: string[]           // расширения: ['.png', '.jpg', '.pdf']
  maxFiles?: number
  maxSizeBytes?: number
  onDrop: (files: DroppedFile[]) => void
  onError?: (error: string) => void
}

export function useFileDrop(options: UseFileDropOptions) {
  const [isDragging, setIsDragging] = useState(false)
  const [isDragOver, setIsDragOver] = useState(false)
  const dragCounter = useRef(0) // счётчик для вложенных элементов

  const validateFile = useCallback(
    (file: File): string | null => {
      if (options.accept && options.accept.length > 0) {
        const ext = '.' + file.name.split('.').pop()?.toLowerCase()
        if (!options.accept.includes(ext)) {
          return `Формат ${ext} не поддерживается`
        }
      }
      if (options.maxSizeBytes && file.size > options.maxSizeBytes) {
        const mb = (options.maxSizeBytes / 1024 / 1024).toFixed(1)
        return `Файл превышает ${mb} МБ`
      }
      return null
    },
    [options.accept, options.maxSizeBytes]
  )

  const handleDragEnter = useCallback((e: DragEvent) => {
    e.preventDefault()
    e.stopPropagation()
    dragCounter.current++

    if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
      setIsDragging(true)
    }
  }, [])

  const handleDragLeave = useCallback((e: DragEvent) => {
    e.preventDefault()
    e.stopPropagation()
    dragCounter.current--

    if (dragCounter.current === 0) {
      setIsDragging(false)
    }
  }, [])

  const handleDragOver = useCallback((e: DragEvent) => {
    e.preventDefault()
    e.stopPropagation()
    // Указываем браузеру разрешить drop
    e.dataTransfer.dropEffect = 'copy'
    setIsDragOver(true)
  }, [])

  const handleDrop = useCallback(
    (e: DragEvent) => {
      e.preventDefault()
      e.stopPropagation()
      setIsDragging(false)
      setIsDragOver(false)
      dragCounter.current = 0

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

      if (options.maxFiles && files.length > options.maxFiles) {
        options.onError?.(`Можно загрузить не более ${options.maxFiles} файлов`)
        return
      }

      const valid: DroppedFile[] = []
      for (const file of files) {
        const error = validateFile(file)
        if (error) {
          options.onError?.(error)
          continue
        }
        valid.push({
          name: file.name,
          // В Electron File-объекты имеют нестандартное свойство path
          path: (file as any).path ?? '',
          size: file.size,
          type: file.type,
          lastModified: file.lastModified,
        })
      }

      if (valid.length > 0) {
        options.onDrop(valid)
      }
    },
    [validateFile, options]
  )

  return {
    isDragging,
    isDragOver,
    dropProps: {
      onDragEnter: handleDragEnter,
      onDragLeave: handleDragLeave,
      onDragOver: handleDragOver,
      onDrop: handleDrop,
    },
  }
}

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

// src/components/DropZone.tsx
import { ReactNode } from 'react'
import { useFileDrop, DroppedFile } from '../hooks/useFileDrop'

interface DropZoneProps {
  children: ReactNode
  accept?: string[]
  maxFiles?: number
  maxSizeMb?: number
  onFilesDropped: (files: DroppedFile[]) => void
  className?: string
}

export function DropZone({
  children,
  accept,
  maxFiles,
  maxSizeMb = 100,
  onFilesDropped,
  className = '',
}: DropZoneProps) {
  const { isDragging, dropProps } = useFileDrop({
    accept,
    maxFiles,
    maxSizeBytes: maxSizeMb * 1024 * 1024,
    onDrop: onFilesDropped,
  })

  return (
    <div
      {...dropProps}
      className={`
        relative transition-all duration-200
        ${isDragging
          ? 'ring-2 ring-blue-500 ring-offset-2 bg-blue-50/30'
          : ''
        }
        ${className}
      `}
    >
      {children}
      {isDragging && (
        <div className="absolute inset-0 flex items-center justify-center bg-blue-500/10 backdrop-blur-sm rounded-lg pointer-events-none z-50">
          <div className="text-center">
            <svg className="w-12 h-12 text-blue-500 mx-auto mb-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
            </svg>
            <p className="text-blue-700 font-medium">Отпустите для загрузки</p>
            {accept && (
              <p className="text-blue-500 text-sm mt-1">{accept.join(', ')}</p>
            )}
          </div>
        </div>
      )}
    </div>
  )
}

Обработка файлов через IPC (main process)

После получения путей файлов в renderer нужно передать их в main для обработки — чтение, копирование, валидация содержимого:

// preload/index.ts
import { contextBridge, ipcRenderer } from 'electron'

contextBridge.exposeInMainWorld('fileSystem', {
  readFile: (filePath: string) =>
    ipcRenderer.invoke('fs:read-file', filePath),
  copyToWorkspace: (sourcePath: string, destName: string) =>
    ipcRenderer.invoke('fs:copy-to-workspace', sourcePath, destName),
  getFileInfo: (filePath: string) =>
    ipcRenderer.invoke('fs:file-info', filePath),
})
// main/ipc-handlers/file-system.ts
import { ipcMain, app } from 'electron'
import fs from 'fs/promises'
import path from 'path'

const workspacePath = path.join(app.getPath('userData'), 'workspace')

ipcMain.handle('fs:read-file', async (_event, filePath: string) => {
  try {
    // Безопасная проверка пути (path traversal prevention)
    const resolved = path.resolve(filePath)
    const buffer = await fs.readFile(resolved)
    return { ok: true, data: buffer.toString('base64'), size: buffer.length }
  } catch (e: any) {
    return { ok: false, error: e.message }
  }
})

ipcMain.handle(
  'fs:copy-to-workspace',
  async (_event, sourcePath: string, destName: string) => {
    try {
      await fs.mkdir(workspacePath, { recursive: true })
      // Sanitize имя файла
      const safeName = path.basename(destName).replace(/[^a-zA-Z0-9._-]/g, '_')
      const destPath = path.join(workspacePath, safeName)
      await fs.copyFile(sourcePath, destPath)
      return { ok: true, destPath }
    } catch (e: any) {
      return { ok: false, error: e.message }
    }
  }
)

ipcMain.handle('fs:file-info', async (_event, filePath: string) => {
  try {
    const stat = await fs.stat(filePath)
    return {
      ok: true,
      size: stat.size,
      mtime: stat.mtimeMs,
      isDirectory: stat.isDirectory(),
    }
  } catch (e: any) {
    return { ok: false, error: e.message }
  }
})

Перетаскивание директорий

Когда пользователь перетаскивает папку, e.dataTransfer.files содержит её как единственный File без содержимого. Чтобы получить файлы внутри, используем webkitGetAsEntry():

// src/utils/directory-reader.ts
export interface FileEntry {
  path: string
  relativePath: string
  file: File
}

export async function readDroppedItems(
  dataTransfer: DataTransfer
): Promise<FileEntry[]> {
  const entries: FileSystemEntry[] = []

  // Используем DataTransferItemList для доступа к entries
  for (let i = 0; i < dataTransfer.items.length; i++) {
    const entry = dataTransfer.items[i].webkitGetAsEntry()
    if (entry) entries.push(entry)
  }

  const files: FileEntry[] = []
  await Promise.all(entries.map(entry => traverseEntry(entry, '', files)))
  return files
}

async function traverseEntry(
  entry: FileSystemEntry,
  relativePath: string,
  result: FileEntry[]
): Promise<void> {
  if (entry.isFile) {
    const file = await getFile(entry as FileSystemFileEntry)
    result.push({
      path: (file as any).path ?? '',
      relativePath: relativePath + file.name,
      file,
    })
  } else if (entry.isDirectory) {
    const reader = (entry as FileSystemDirectoryEntry).createReader()
    const entries = await readAllEntries(reader)
    await Promise.all(
      entries.map(e =>
        traverseEntry(e, relativePath + entry.name + '/', result)
      )
    )
  }
}

function getFile(entry: FileSystemFileEntry): Promise<File> {
  return new Promise((resolve, reject) => entry.file(resolve, reject))
}

function readAllEntries(
  reader: FileSystemDirectoryReader
): Promise<FileSystemEntry[]> {
  return new Promise((resolve, reject) => {
    const results: FileSystemEntry[] = []
    const read = () => {
      reader.readEntries(entries => {
        if (entries.length === 0) {
          resolve(results)
        } else {
          results.push(...entries)
          read()
        }
      }, reject)
    }
    read()
  })
}

Визуальная обратная связь: прогресс загрузки

// src/hooks/useFileUploadProgress.ts
import { useState } from 'react'

interface UploadState {
  [fileName: string]: {
    progress: number
    status: 'pending' | 'uploading' | 'done' | 'error'
    error?: string
  }
}

export function useFileUploadProgress() {
  const [uploads, setUploads] = useState<UploadState>({})

  const startUpload = (fileName: string) => {
    setUploads(prev => ({
      ...prev,
      [fileName]: { progress: 0, status: 'uploading' },
    }))
  }

  const updateProgress = (fileName: string, progress: number) => {
    setUploads(prev => ({
      ...prev,
      [fileName]: { ...prev[fileName], progress },
    }))
  }

  const finishUpload = (fileName: string, error?: string) => {
    setUploads(prev => ({
      ...prev,
      [fileName]: {
        progress: error ? prev[fileName]?.progress ?? 0 : 100,
        status: error ? 'error' : 'done',
        error,
      },
    }))
  }

  return { uploads, startUpload, updateProgress, finishUpload }
}

Типичные сроки

Базовая DropZone с валидацией — 4–6 часов. С поддержкой папок, прогрессом, IPC в main, безопасной работой с путями и тестами — 2–3 рабочих дня.