Реалізація Drag-and-Drop файлів із системи в десктоп-приложение
Перетаскування файлів з файлового менеджера в вікно приложения — базовий сценарій для більшості продуктивних інструментів. У Electron це реалізується через стандартні drag-and-drop события браузера (renderer) плюс опціональну нативну обробку (main). У Tauri — через tauri-plugin-drag-drop.
Базовий обробник в Renderer
HTML5 Drag-and-Drop API працює в Electron renderer як в звичайному браузері. Головна відмінність: при перетаскуванні файлів з ОС, e.dataTransfer.files містить File-об'єкти з нативними шляхами до файлів.
// src/hooks/useFileDrop.ts
import { useRef, useState, useCallback } from 'react'
export interface DroppedFile {
name: string
path: string
size: number
type: string
lastModified: number
}
interface UseFileDropOptions {
accept?: string[]
maxFiles?: number
maxSizeBytes?: number
onDrop: (files: DroppedFile[]) => void
onError?: (error: string) => void
}
export function useFileDrop(options: UseFileDropOptions) {
const [isDragging, setIsDragging] = 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()
dragCounter.current++
setIsDragging(true)
}, [])
const handleDragLeave = useCallback((e: DragEvent) => {
e.preventDefault()
dragCounter.current--
if (dragCounter.current === 0) {
setIsDragging(false)
}
}, [])
const handleDrop = useCallback((e: DragEvent) => {
e.preventDefault()
setIsDragging(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,
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,
dropProps: {
onDragEnter: handleDragEnter,
onDragLeave: handleDragLeave,
onDragOver: (e: DragEvent) => {
e.preventDefault()
e.dataTransfer.dropEffect = 'copy'
},
onDrop: handleDrop,
},
}
}
Компонент DropZone
// src/components/DropZone.tsx
interface DropZoneProps {
accept?: string[]
maxFiles?: number
maxSizeMb?: number
onFilesDropped: (files: DroppedFile[]) => void
}
export function DropZone({ accept, maxFiles, maxSizeMb = 100, onFilesDropped }: DropZoneProps) {
const { isDragging, dropProps } = useFileDrop({
accept,
maxFiles,
maxSizeBytes: maxSizeMb * 1024 * 1024,
onDrop: onFilesDropped,
})
return (
<div
{...dropProps}
className={`relative p-8 border-2 border-dashed rounded-lg transition-all
${isDragging ? 'border-blue-500 bg-blue-50' : 'border-gray-300'}`}
>
{isDragging && (
<div className="absolute inset-0 flex items-center justify-center bg-blue-500/10">
<p className="text-blue-700 font-medium">Відпустіть файли тут</p>
</div>
)}
</div>
)
}
Обробка файлів через IPC
// preload/index.ts
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),
})
// main/ipc-handlers/file-system.ts
const workspacePath = path.join(app.getPath('userData'), 'workspace')
ipcMain.handle('fs:read-file', async (_event, filePath: string) => {
try {
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 })
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 }
}
})
Перетаскування директорій
Коли користувач перетягує папку, e.dataTransfer.files містить її як один File. Використовуйте webkitGetAsEntry() для доступу до вмісту:
// src/utils/directory-reader.ts
export async function readDroppedItems(dataTransfer: DataTransfer): Promise<FileEntry[]> {
const entries: FileSystemEntry[] = []
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))
)
}
}
Базова DropZone з валідацією: 4–6 годин. З підтримкою папок, прогресом, IPC в main, безпечною роботою з шляхами та тестами: 2–3 робочих дні.







