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:
@@ -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>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user