Реализация 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 рабочих дня.







