feat(editor): DnD сцен в список, порядок списка и правки импорта

Можно перетащить изображения/видео в колонку сцен: создаются новые сцены
с превью и названием из файла, с полноэкранным прогрессом и отчётом о
пропущенных файлах.

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-07-12 10:32:50 +08:00
parent b7e6ff6915
commit 35a6e979eb
18 changed files with 789 additions and 73 deletions
+81 -15
View File
@@ -1,4 +1,4 @@
import { useCallback, useRef, useState, type DragEvent } from 'react';
import { useCallback, useEffect, useRef, useState, type DragEvent } from 'react';
import { getDndApi } from '../shared/dndApi';
@@ -20,13 +20,26 @@ function fileExtension(filePath: string): string {
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) => getPathForFile(file))
.filter((path) => path.length > 0);
return Array.from(files).map((file) => ({
path: getPathForFile(file),
name: file.name || 'file',
}));
}
export function filterAudioFilePaths(paths: string[]): string[] {
@@ -40,69 +53,122 @@ export function pickFirstPreviewFilePath(paths: string[]): string | null {
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 {
return Array.from(e.dataTransfer?.types ?? []).includes('Files');
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;
onDropPaths: (paths: string[]) => void;
/** Синхронная блокировка (например, идёт 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 (disabled || !dragHasFiles(e)) return;
if (blocked() || !dragHasFiles(e)) return;
e.preventDefault();
e.stopPropagation();
depthRef.current += 1;
setDragOver(true);
},
[disabled],
[blocked],
);
const onDragLeave = useCallback(
(e: DragEvent) => {
if (disabled) return;
if (blocked()) return;
e.preventDefault();
e.stopPropagation();
depthRef.current = Math.max(0, depthRef.current - 1);
if (depthRef.current === 0) setDragOver(false);
},
[disabled],
[blocked],
);
const onDragOver = useCallback(
(e: DragEvent) => {
if (disabled || !dragHasFiles(e)) return;
if (blocked() || !dragHasFiles(e)) return;
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = 'copy';
},
[disabled],
[blocked],
);
const onDrop = useCallback(
(e: DragEvent) => {
if (disabled || !dragHasFiles(e)) return;
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);
onDropPaths?.(paths);
},
[disabled, filterPaths, onDropPaths],
[blocked, filterPaths, onDropEntries, onDropPaths],
);
return { dragOver, onDragEnter, onDragLeave, onDragOver, onDrop };