feat(editor): drag-and-drop для аудио и превью сцены
Добавлена загрузка файлов перетаскиванием в блоки «Аудио игры», «Аудио сцены» и «Превью сцены»: подсветка зоны, подсказки, фильтрация по типу, импорт без диалога. IPC и main принимают пути файлов напрямую; для Electron 41 пути получаются через webUtils.getPathForFile в preload (File.path в renderer больше недоступен). Улучшен dev-запуск: Vite слушает 127.0.0.1, ожидание готовности по localhost. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user