b7e6ff6915
Добавлена загрузка файлов перетаскиванием в блоки «Аудио игры», «Аудио сцены» и «Превью сцены»: подсветка зоны, подсказки, фильтрация по типу, импорт без диалога. IPC и main принимают пути файлов напрямую; для Electron 41 пути получаются через webUtils.getPathForFile в preload (File.path в renderer больше недоступен). Улучшен dev-запуск: Vite слушает 127.0.0.1, ожидание готовности по localhost. Co-authored-by: Cursor <cursoragent@cursor.com>
110 lines
2.8 KiB
TypeScript
110 lines
2.8 KiB
TypeScript
import { useCallback, useRef, useState, type DragEvent } from 'react';
|
|
|
|
import { getDndApi } from '../shared/dndApi';
|
|
|
|
const AUDIO_EXTENSIONS = new Set(['.mp3', '.wav', '.ogg', '.m4a', '.aac']);
|
|
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 function getDroppedFilePaths(e: DragEvent): string[] {
|
|
const files = e.dataTransfer?.files;
|
|
if (!files?.length) return [];
|
|
const getPathForFile = getDndApi().getPathForFile;
|
|
return Array.from(files)
|
|
.map((file) => getPathForFile(file))
|
|
.filter((path) => path.length > 0);
|
|
}
|
|
|
|
export function filterAudioFilePaths(paths: string[]): string[] {
|
|
return paths.filter((path) => AUDIO_EXTENSIONS.has(fileExtension(path)));
|
|
}
|
|
|
|
export function pickFirstPreviewFilePath(paths: string[]): string | null {
|
|
for (const path of paths) {
|
|
if (PREVIEW_EXTENSIONS.has(fileExtension(path))) return path;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function dragHasFiles(e: DragEvent): boolean {
|
|
return Array.from(e.dataTransfer?.types ?? []).includes('Files');
|
|
}
|
|
|
|
type UseFileDropZoneOptions = {
|
|
disabled?: boolean;
|
|
onDropPaths: (paths: string[]) => void;
|
|
filterPaths?: (paths: string[]) => string[];
|
|
};
|
|
|
|
export function useFileDropZone({
|
|
disabled = false,
|
|
onDropPaths,
|
|
filterPaths,
|
|
}: UseFileDropZoneOptions) {
|
|
const [dragOver, setDragOver] = useState(false);
|
|
const depthRef = useRef(0);
|
|
|
|
const onDragEnter = useCallback(
|
|
(e: DragEvent) => {
|
|
if (disabled || !dragHasFiles(e)) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
depthRef.current += 1;
|
|
setDragOver(true);
|
|
},
|
|
[disabled],
|
|
);
|
|
|
|
const onDragLeave = useCallback(
|
|
(e: DragEvent) => {
|
|
if (disabled) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
depthRef.current = Math.max(0, depthRef.current - 1);
|
|
if (depthRef.current === 0) setDragOver(false);
|
|
},
|
|
[disabled],
|
|
);
|
|
|
|
const onDragOver = useCallback(
|
|
(e: DragEvent) => {
|
|
if (disabled || !dragHasFiles(e)) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
e.dataTransfer.dropEffect = 'copy';
|
|
},
|
|
[disabled],
|
|
);
|
|
|
|
const onDrop = useCallback(
|
|
(e: DragEvent) => {
|
|
if (disabled || !dragHasFiles(e)) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
depthRef.current = 0;
|
|
setDragOver(false);
|
|
const raw = getDroppedFilePaths(e);
|
|
const paths = filterPaths ? filterPaths(raw) : raw;
|
|
if (paths.length === 0) return;
|
|
onDropPaths(paths);
|
|
},
|
|
[disabled, filterPaths, onDropPaths],
|
|
);
|
|
|
|
return { dragOver, onDragEnter, onDragLeave, onDragOver, onDrop };
|
|
}
|