import { useCallback, useEffect, useRef, useState, type DragEvent } from 'react'; import { getDndApi } from '../shared/dndApi'; const AUDIO_EXTENSIONS = new Set(['.mp3', '.wav', '.ogg', '.m4a', '.aac']); const MATERIAL_IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.webp']); const PREVIEW_EXTENSIONS = new Set([ '.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp', '.mp4', '.webm', '.mov', ]); function fileExtension(filePath: string): string { const dot = filePath.lastIndexOf('.'); return dot >= 0 ? filePath.slice(dot).toLowerCase() : ''; } export type DroppedFileEntry = { path: string; name: string }; export type SceneMediaDropRejectReason = 'unsupported' | 'no_path'; export type SceneMediaDropRejected = { name: string; reason: SceneMediaDropRejectReason }; export function getDroppedFilePaths(e: DragEvent): string[] { return getDroppedFileEntries(e) .map((entry) => entry.path) .filter((path) => path.length > 0); } export function getDroppedFileEntries(e: DragEvent): DroppedFileEntry[] { const files = e.dataTransfer?.files; if (!files?.length) return []; const getPathForFile = getDndApi().getPathForFile; return Array.from(files).map((file) => ({ path: getPathForFile(file), name: file.name || 'file', })); } export function filterAudioFilePaths(paths: string[]): string[] { return paths.filter((path) => AUDIO_EXTENSIONS.has(fileExtension(path))); } export function filterMaterialImagePaths(paths: string[]): string[] { return paths.filter((path) => MATERIAL_IMAGE_EXTENSIONS.has(fileExtension(path))); } export function pickFirstMaterialImagePath(paths: string[]): string | null { for (const path of paths) { if (MATERIAL_IMAGE_EXTENSIONS.has(fileExtension(path))) return path; } return null; } export function pickFirstPreviewFilePath(paths: string[]): string | null { for (const path of paths) { if (PREVIEW_EXTENSIONS.has(fileExtension(path))) return path; } return null; } export function isPreviewMediaPath(filePath: string): boolean { return PREVIEW_EXTENSIONS.has(fileExtension(filePath)); } export function sceneTitleFromMediaPath(filePath: string): string { const base = filePath.split(/[/\\]/).pop() ?? filePath; const dot = base.lastIndexOf('.'); const title = (dot > 0 ? base.slice(0, dot) : base).trim(); return title.length > 0 ? title : 'Новая сцена'; } export function partitionSceneMediaDrops(entries: DroppedFileEntry[]): { accepted: DroppedFileEntry[]; rejected: SceneMediaDropRejected[]; } { const accepted: DroppedFileEntry[] = []; const rejected: SceneMediaDropRejected[] = []; for (const entry of entries) { if (!entry.path) { rejected.push({ name: entry.name, reason: 'no_path' }); continue; } if (!isPreviewMediaPath(entry.path)) { rejected.push({ name: entry.name, reason: 'unsupported' }); continue; } accepted.push(entry); } return { accepted, rejected }; } function dragHasFiles(e: DragEvent): boolean { const types = Array.from(e.dataTransfer?.types ?? []); // Внутренний drag карточки сцены (в т.ч. с превью-картинкой) не должен считаться файловым. if (types.some((t) => t === 'application/x-dnd-scene-id')) return false; return types.includes('Files'); } type UseFileDropZoneOptions = { disabled?: boolean; /** Синхронная блокировка (например, идёт reorder списка сцен). */ isBlocked?: () => boolean; onDropPaths?: (paths: string[]) => void; onDropEntries?: (entries: DroppedFileEntry[]) => void; filterPaths?: (paths: string[]) => string[]; }; export function useFileDropZone({ disabled = false, isBlocked, onDropPaths, onDropEntries, filterPaths, }: UseFileDropZoneOptions) { const [dragOver, setDragOver] = useState(false); const depthRef = useRef(0); const blocked = useCallback(() => disabled || Boolean(isBlocked?.()), [disabled, isBlocked]); useEffect(() => { if (!blocked()) return; depthRef.current = 0; setDragOver(false); }, [blocked]); const onDragEnter = useCallback( (e: DragEvent) => { if (blocked() || !dragHasFiles(e)) return; e.preventDefault(); e.stopPropagation(); depthRef.current += 1; setDragOver(true); }, [blocked], ); const onDragLeave = useCallback( (e: DragEvent) => { if (blocked()) return; e.preventDefault(); e.stopPropagation(); depthRef.current = Math.max(0, depthRef.current - 1); if (depthRef.current === 0) setDragOver(false); }, [blocked], ); const onDragOver = useCallback( (e: DragEvent) => { if (blocked() || !dragHasFiles(e)) return; e.preventDefault(); e.stopPropagation(); e.dataTransfer.dropEffect = 'copy'; }, [blocked], ); const onDrop = useCallback( (e: DragEvent) => { if (blocked() || !dragHasFiles(e)) return; e.preventDefault(); e.stopPropagation(); depthRef.current = 0; setDragOver(false); if (onDropEntries) { const entries = getDroppedFileEntries(e); if (entries.length === 0) return; onDropEntries(entries); return; } const raw = getDroppedFilePaths(e); const paths = filterPaths ? filterPaths(raw) : raw; if (paths.length === 0) return; onDropPaths?.(paths); }, [blocked, filterPaths, onDropEntries, onDropPaths], ); return { dragOver, onDragEnter, onDragLeave, onDragOver, onDrop }; }