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:
Ivan Fontosh
2026-07-09 19:22:51 +08:00
parent c8ab9dd567
commit b7e6ff6915
10 changed files with 353 additions and 52 deletions
+97 -3
View File
@@ -25,6 +25,12 @@ import { Button, Input } from '../shared/ui/controls';
import { LayoutShell } from '../shared/ui/LayoutShell';
import { useAssetUrl } from '../shared/useAssetImageUrl';
import {
filterAudioFilePaths,
pickFirstPreviewFilePath,
useFileDropZone,
} from './fileDrop';
import type { SceneImportResolution, StorylineImportMergeReport, StorylineSelection } from '../../shared/graph/storylineExportImport';
import {
buildSceneResolutionsForImport,
@@ -765,6 +771,18 @@ export function EditorApp() {
}
})();
}}
onDropAudio={(filePaths) => {
void (async () => {
try {
await actions.importCampaignAudioFromPaths(filePaths);
} catch (e) {
setAppNotice({
title: t('common.error'),
message: e instanceof Error ? e.message : String(e),
});
}
})();
}}
/>
<div className={styles.spacer18} />
<div className={styles.inspectorTitle}>{t('scenes.inspectorScene')}</div>
@@ -834,6 +852,34 @@ export function EditorApp() {
void actions.updateScene(sid, { previewRotationDeg })
}
onUploadMedia={() => void actions.importMediaToScene(sid)}
onDropAudio={(filePaths) => {
void (async () => {
try {
await actions.importMediaToSceneFromPaths(sid, filePaths);
} catch (e) {
setAppNotice({
title: t('common.error'),
message: e instanceof Error ? e.message : String(e),
});
}
})();
}}
onDropPreview={(filePath) => {
if (previewBusy) return;
setPreviewDialogSceneId(sid);
void (async () => {
try {
await actions.importScenePreviewFromPath(sid, filePath);
} catch (e) {
setAppNotice({
title: t('common.error'),
message: e instanceof Error ? e.message : String(e),
});
} finally {
setPreviewDialogSceneId((cur) => (cur === sid ? null : cur));
}
})();
}}
onSideStoryLineTitleChange={(graphNodeId, title) =>
void actions.updateSideStoryLineTitle(graphNodeId, title)
}
@@ -1886,6 +1932,8 @@ type SceneInspectorProps = {
onClearPreview: () => void;
onRotatePreview: (deg: 0 | 90 | 180 | 270) => void;
onUploadMedia: () => void;
onDropAudio: (filePaths: string[]) => void;
onDropPreview: (filePath: string) => void;
onSideStoryLineTitleChange: (graphNodeId: GraphNodeId, title: string) => void;
};
@@ -1894,6 +1942,7 @@ type CampaignInspectorProps = {
audioRefs: SceneAudioRef[];
onAudioRefsChange: (next: SceneAudioRef[]) => void;
onUploadAudio: () => void;
onDropAudio: (filePaths: string[]) => void;
};
function CampaignInspector({
@@ -1901,13 +1950,27 @@ function CampaignInspector({
audioRefs,
onAudioRefsChange,
onUploadAudio,
onDropAudio,
}: CampaignInspectorProps) {
const { t } = useEditorI18n();
const audioById = useMemo(() => new Map(audioRefs.map((a) => [a.assetId, a])), [audioRefs]);
const audioDrop = useFileDropZone({
onDropPaths: onDropAudio,
filterPaths: filterAudioFilePaths,
});
return (
<div className={styles.sceneInspector}>
<div className={styles.labelSm}>{t('campaign.label')}</div>
<div className={styles.audioDrop}>
<div
className={[styles.audioDrop, audioDrop.dragOver ? styles.audioDropDragOver : ''].join(' ')}
onDragEnter={audioDrop.onDragEnter}
onDragLeave={audioDrop.onDragLeave}
onDragOver={audioDrop.onDragOver}
onDrop={audioDrop.onDrop}
>
{audioDrop.dragOver ? (
<div className={styles.dropHintOverlay}>{t('drop.hintAudio')}</div>
) : null}
{mediaAssets.filter((a) => a.type === 'audio').length === 0 ? (
<div className={[styles.muted, styles.spanSm].join(' ')}>{t('campaign.noFiles')}</div>
) : (
@@ -1998,11 +2061,24 @@ function SceneInspector({
onClearPreview,
onRotatePreview,
onUploadMedia,
onDropAudio,
onDropPreview,
onSideStoryLineTitleChange,
}: SceneInspectorProps) {
const { t } = useEditorI18n();
const previewUrl = useAssetUrl(previewAssetId);
const audioById = useMemo(() => new Map(audioRefs.map((a) => [a.assetId, a])), [audioRefs]);
const previewDrop = useFileDropZone({
disabled: previewBusy,
onDropPaths: (paths) => {
const filePath = pickFirstPreviewFilePath(paths);
if (filePath) onDropPreview(filePath);
},
});
const sceneAudioDrop = useFileDropZone({
onDropPaths: onDropAudio,
filterPaths: filterAudioFilePaths,
});
return (
<div className={styles.sceneInspector}>
<div className={styles.labelSm}>{t('scene.title')}</div>
@@ -2032,7 +2108,16 @@ function SceneInspector({
<div className={styles.spacer6} />
<div className={styles.labelSm}>{t('scene.preview')}</div>
<div className={styles.hint}>{t('scene.previewHint')}</div>
<div className={styles.previewBox}>
<div
className={[styles.previewBox, previewDrop.dragOver ? styles.previewBoxDragOver : ''].join(' ')}
onDragEnter={previewDrop.onDragEnter}
onDragLeave={previewDrop.onDragLeave}
onDragOver={previewDrop.onDragOver}
onDrop={previewDrop.onDrop}
>
{previewDrop.dragOver ? (
<div className={styles.dropHintOverlay}>{t('drop.hintPreview')}</div>
) : null}
{previewUrl && previewAssetType === 'image' ? (
<div className={styles.previewFill}>
<RotatedImage
@@ -2107,7 +2192,16 @@ function SceneInspector({
) : null}
<div className={styles.spacer6} />
<div className={styles.labelSm}>{t('scene.audio')}</div>
<div className={styles.audioDrop}>
<div
className={[styles.audioDrop, sceneAudioDrop.dragOver ? styles.audioDropDragOver : ''].join(' ')}
onDragEnter={sceneAudioDrop.onDragEnter}
onDragLeave={sceneAudioDrop.onDragLeave}
onDragOver={sceneAudioDrop.onDragOver}
onDrop={sceneAudioDrop.onDrop}
>
{sceneAudioDrop.dragOver ? (
<div className={styles.dropHintOverlay}>{t('drop.hintAudio')}</div>
) : null}
{mediaAssets.filter((a) => a.type === 'audio').length === 0 ? (
<div className={[styles.muted, styles.spanSm].join(' ')}>{t('campaign.noFiles')}</div>
) : (