Files
DndGamePlayer/app/renderer/editor/fileDrop.ts
T
Ivan Fontosh 35a6e979eb feat(editor): DnD сцен в список, порядок списка и правки импорта
Можно перетащить изображения/видео в колонку сцен: создаются новые сцены
с превью и названием из файла, с полноэкранным прогрессом и отчётом о
пропущенных файлах.

Добавлен ручной порядок сцен в списке (sceneListOrder) с сохранением в
проект; перетаскивание карточек отделено от импорта файлов. Во время
сортировки снова работает скролл колесом.

Исправлен залипающий disabled у «+ Новая сцена» после создания сцены.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 10:32:50 +08:00

176 lines
5.0 KiB
TypeScript

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 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 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 };
}