61875be857
Let GMs manage and show images over the scene from the editor and control panel, with zoom tools, help docs, and full ru/en i18n. Co-authored-by: Cursor <cursoragent@cursor.com>
188 lines
5.5 KiB
TypeScript
188 lines
5.5 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 MATERIAL_IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.webp']);
|
|
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 filterMaterialImagePaths(paths: string[]): string[] {
|
|
return paths.filter((path) => MATERIAL_IMAGE_EXTENSIONS.has(fileExtension(path)));
|
|
}
|
|
|
|
export function pickFirstMaterialImagePath(paths: string[]): string | null {
|
|
for (const path of paths) {
|
|
if (MATERIAL_IMAGE_EXTENSIONS.has(fileExtension(path))) return path;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
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 };
|
|
}
|