feat(editor): DnD сцен в список, порядок списка и правки импорта
Можно перетащить изображения/видео в колонку сцен: создаются новые сцены с превью и названием из файла, с полноэкранным прогрессом и отчётом о пропущенных файлах. Добавлен ручной порядок сцен в списке (sceneListOrder) с сохранением в проект; перетаскивание карточек отделено от импорта файлов. Во время сортировки снова работает скролл колесом. Исправлен залипающий disabled у «+ Новая сцена» после создания сцены. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user