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
+20 -8
View File
@@ -392,8 +392,10 @@ async function main() {
emitSessionState();
return { scene: next };
});
registerHandler(ipcChannels.project.importMedia, async ({ sceneId }) => {
const { canceled, filePaths } = await dialog.showOpenDialog({
registerHandler(ipcChannels.project.importMedia, async ({ sceneId, filePaths: pathsFromDrop }) => {
let filePaths = pathsFromDrop ?? [];
if (!pathsFromDrop) {
const { canceled, filePaths: picked } = await dialog.showOpenDialog({
properties: ['openFile', 'multiSelections'],
filters: [
{
@@ -402,17 +404,21 @@ async function main() {
},
],
});
if (canceled || filePaths.length === 0) {
if (canceled || picked.length === 0) {
const project = projectStore.getOpenProject();
if (!project) throw new Error('No open project');
return { project, imported: [] };
}
filePaths = picked;
}
const result = await projectStore.importMediaFiles(sceneId, filePaths);
emitSessionState();
return result;
});
registerHandler(ipcChannels.project.importCampaignAudio, async () => {
const { canceled, filePaths } = await dialog.showOpenDialog({
registerHandler(ipcChannels.project.importCampaignAudio, async ({ filePaths: pathsFromDrop }) => {
let filePaths = pathsFromDrop ?? [];
if (!pathsFromDrop) {
const { canceled, filePaths: picked } = await dialog.showOpenDialog({
properties: ['openFile', 'multiSelections'],
filters: [
{
@@ -421,11 +427,13 @@ async function main() {
},
],
});
if (canceled || filePaths.length === 0) {
if (canceled || picked.length === 0) {
const project = projectStore.getOpenProject();
if (!project) throw new Error('No open project');
return { canceled: true as const, project, imported: [] };
}
filePaths = picked;
}
const result = await projectStore.importCampaignAudioFiles(filePaths);
emitSessionState();
return { canceled: false as const, ...result };
@@ -435,7 +443,9 @@ async function main() {
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.importScenePreview, async ({ sceneId }) => {
registerHandler(ipcChannels.project.importScenePreview, async ({ sceneId, filePath: pathFromDrop }) => {
let filePath = pathFromDrop;
if (!filePath) {
const { canceled, filePaths } = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [
@@ -450,7 +460,9 @@ async function main() {
if (!project) throw new Error('No open project');
return { project, assetId: null, background: false };
}
const result = await projectStore.importScenePreviewMedia(sceneId, filePaths[0]);
filePath = filePaths[0];
}
const result = await projectStore.importScenePreviewMedia(sceneId, filePath);
emitSessionState();
emitScenePreviewImportProgress({
sceneId,
+7 -2
View File
@@ -1,4 +1,4 @@
import { contextBridge } from 'electron';
import { contextBridge, webUtils } from 'electron';
import type { IpcEventMap, IpcInvokeMap } from '../shared/ipc/contracts';
@@ -10,9 +10,14 @@ export type DndApi = {
payload: IpcInvokeMap[K]['req'],
) => Promise<IpcInvokeMap[K]['res']>;
on: <K extends keyof IpcEventMap>(channel: K, listener: (payload: IpcEventMap[K]) => void) => () => void;
getPathForFile: (file: File) => string;
};
const api: DndApi = { invoke, on };
const api: DndApi = {
invoke,
on,
getPathForFile: (file) => webUtils.getPathForFile(file),
};
contextBridge.exposeInMainWorld('dnd', api);
+35
View File
@@ -661,6 +661,15 @@
align-items: center;
justify-content: center;
position: relative;
transition:
border-color 0.15s ease,
background 0.15s ease;
}
.previewBoxDragOver {
border-color: var(--accent);
border-style: dashed;
background: var(--accent-fill-soft);
}
.previewFill {
@@ -754,6 +763,32 @@
padding: 10px;
display: grid;
gap: 8px;
position: relative;
transition:
border-color 0.15s ease,
background 0.15s ease;
}
.audioDropDragOver {
border-color: var(--accent);
background: var(--accent-fill-soft);
}
.dropHintOverlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 10px;
border-radius: inherit;
background: var(--accent-fill-soft-2);
color: var(--accent);
font-size: var(--text-xs);
font-weight: 700;
text-align: center;
pointer-events: none;
z-index: 2;
}
.audioList {
+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>
) : (
+109
View File
@@ -0,0 +1,109 @@
import { useCallback, useRef, useState, type DragEvent } from 'react';
import { getDndApi } from '../shared/dndApi';
const AUDIO_EXTENSIONS = new Set(['.mp3', '.wav', '.ogg', '.m4a', '.aac']);
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 function getDroppedFilePaths(e: DragEvent): string[] {
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);
}
export function filterAudioFilePaths(paths: string[]): string[] {
return paths.filter((path) => AUDIO_EXTENSIONS.has(fileExtension(path)));
}
export function pickFirstPreviewFilePath(paths: string[]): string | null {
for (const path of paths) {
if (PREVIEW_EXTENSIONS.has(fileExtension(path))) return path;
}
return null;
}
function dragHasFiles(e: DragEvent): boolean {
return Array.from(e.dataTransfer?.types ?? []).includes('Files');
}
type UseFileDropZoneOptions = {
disabled?: boolean;
onDropPaths: (paths: string[]) => void;
filterPaths?: (paths: string[]) => string[];
};
export function useFileDropZone({
disabled = false,
onDropPaths,
filterPaths,
}: UseFileDropZoneOptions) {
const [dragOver, setDragOver] = useState(false);
const depthRef = useRef(0);
const onDragEnter = useCallback(
(e: DragEvent) => {
if (disabled || !dragHasFiles(e)) return;
e.preventDefault();
e.stopPropagation();
depthRef.current += 1;
setDragOver(true);
},
[disabled],
);
const onDragLeave = useCallback(
(e: DragEvent) => {
if (disabled) return;
e.preventDefault();
e.stopPropagation();
depthRef.current = Math.max(0, depthRef.current - 1);
if (depthRef.current === 0) setDragOver(false);
},
[disabled],
);
const onDragOver = useCallback(
(e: DragEvent) => {
if (disabled || !dragHasFiles(e)) return;
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = 'copy';
},
[disabled],
);
const onDrop = useCallback(
(e: DragEvent) => {
if (disabled || !dragHasFiles(e)) return;
e.preventDefault();
e.stopPropagation();
depthRef.current = 0;
setDragOver(false);
const raw = getDroppedFilePaths(e);
const paths = filterPaths ? filterPaths(raw) : raw;
if (paths.length === 0) return;
onDropPaths(paths);
},
[disabled, filterPaths, onDropPaths],
);
return { dragOver, onDragEnter, onDragLeave, onDragOver, onDrop };
}
@@ -313,6 +313,8 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'campaign.loop': 'Цикл',
'campaign.removeTitle': 'Убрать из кампании',
'campaign.upload': 'Загрузить',
'drop.hintAudio': 'Перетащите аудиофайлы сюда',
'drop.hintPreview': 'Перетащите изображение или видео',
'scene.title': 'НАЗВАНИЕ СЦЕНЫ',
'scene.description': 'ОПИСАНИЕ',
@@ -689,6 +691,8 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'campaign.loop': 'Loop',
'campaign.removeTitle': 'Remove from campaign',
'campaign.upload': 'Upload',
'drop.hintAudio': 'Drop audio files here',
'drop.hintPreview': 'Drop an image or video',
'scene.title': 'SCENE TITLE',
'scene.description': 'DESCRIPTION',
+41
View File
@@ -34,6 +34,7 @@ type Actions = {
createScene: () => Promise<void>;
selectScene: (id: SceneId) => Promise<void>;
importCampaignAudio: () => Promise<void>;
importCampaignAudioFromPaths: (filePaths: string[]) => Promise<void>;
updateCampaignAudios: (next: Project['campaignAudios']) => Promise<void>;
updateScene: (
sceneId: SceneId,
@@ -53,7 +54,12 @@ type Actions = {
) => Promise<void>;
updateConnections: (sceneId: SceneId, connections: SceneId[]) => Promise<void>;
importMediaToScene: (sceneId: SceneId) => Promise<void>;
importMediaToSceneFromPaths: (sceneId: SceneId, filePaths: string[]) => Promise<void>;
importScenePreview: (sceneId: SceneId) => Promise<{ assetId: AssetId | null; background: boolean }>;
importScenePreviewFromPath: (
sceneId: SceneId,
filePath: string,
) => Promise<{ assetId: AssetId | null; background: boolean }>;
clearScenePreview: (sceneId: SceneId) => Promise<void>;
updateSceneGraphNodePosition: (nodeId: GraphNodeId, x: number, y: number) => Promise<void>;
addSceneGraphNode: (sceneId: SceneId, x: number, y: number) => Promise<void>;
@@ -348,6 +354,14 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
await refreshProjects();
};
const importCampaignAudioFromPaths = async (filePaths: string[]) => {
if (filePaths.length === 0) return;
const res = await api.invoke(ipcChannels.project.importCampaignAudio, { filePaths });
if (res.imported.length === 0) return;
setState((s) => ({ ...s, project: res.project }));
await refreshProjects();
};
const updateCampaignAudios = async (next: Project['campaignAudios']) => {
const res = await api.invoke(ipcChannels.project.updateCampaignAudios, { audios: next });
setState((s) => ({ ...s, project: res.project }));
@@ -422,6 +436,14 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
await refreshProjects();
};
const importMediaToSceneFromPaths = async (sceneId: SceneId, filePaths: string[]) => {
if (filePaths.length === 0) return;
const res = await api.invoke(ipcChannels.project.importMedia, { sceneId, filePaths });
if (res.imported.length === 0) return;
setState((s) => ({ ...s, project: res.project }));
await refreshProjects();
};
const importScenePreview = async (sceneId: SceneId) => {
const res = await api.invoke(ipcChannels.project.importScenePreview, { sceneId });
setState((s) => ({ ...s, project: res.project }));
@@ -438,6 +460,22 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
return { assetId: res.assetId, background: res.background };
};
const importScenePreviewFromPath = async (sceneId: SceneId, filePath: string) => {
const res = await api.invoke(ipcChannels.project.importScenePreview, { sceneId, filePath });
setState((s) => ({ ...s, project: res.project }));
if (res.assetId !== null && res.background) {
setState((s) => ({
...s,
scenePreviewImports: {
...s.scenePreviewImports,
[sceneId]: { assetId: res.assetId, phase: 'queued' },
},
}));
}
await refreshProjects();
return { assetId: res.assetId, background: res.background };
};
const clearScenePreview = async (sceneId: SceneId) => {
const res = await api.invoke(ipcChannels.project.clearScenePreview, { sceneId });
setState((s) => ({ ...s, project: res.project }));
@@ -650,11 +688,14 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
createScene,
selectScene,
importCampaignAudio,
importCampaignAudioFromPaths,
updateCampaignAudios,
updateScene,
updateConnections,
importMediaToScene,
importMediaToSceneFromPaths,
importScenePreview,
importScenePreviewFromPath,
clearScenePreview,
updateSceneGraphNodePosition,
addSceneGraphNode,
+3 -3
View File
@@ -219,11 +219,11 @@ export type IpcInvokeMap = {
res: { currentGraphNodeId: GraphNodeId | null; currentSceneId: SceneId | null };
};
[ipcChannels.project.importMedia]: {
req: { sceneId: SceneId };
req: { sceneId: SceneId; filePaths?: string[] };
res: { project: Project; imported: MediaAsset[] };
};
[ipcChannels.project.importCampaignAudio]: {
req: Record<string, never>;
req: { filePaths?: string[] };
res: { canceled: boolean; project: Project; imported: MediaAsset[] };
};
[ipcChannels.project.updateCampaignAudios]: {
@@ -231,7 +231,7 @@ export type IpcInvokeMap = {
res: { project: Project };
};
[ipcChannels.project.importScenePreview]: {
req: { sceneId: SceneId };
req: { sceneId: SceneId; filePath?: string };
res: { project: Project; assetId: AssetId | null; background: boolean };
};
[ipcChannels.project.clearScenePreview]: {
+1 -1
View File
@@ -49,7 +49,7 @@ function killTree(child) {
}
}
function waitForVite(url = 'http://127.0.0.1:5173/editor.html', timeoutMs = 60000) {
function waitForVite(url = 'http://localhost:5173/editor.html', timeoutMs = 60000) {
const started = Date.now();
return new Promise((resolve, reject) => {
const tick = () => {
+1
View File
@@ -57,6 +57,7 @@ export default defineConfig(({ mode }) => {
},
},
server: {
host: '127.0.0.1',
port: 5173,
strictPort: true,
},