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:
+55
-43
@@ -392,39 +392,47 @@ async function main() {
|
|||||||
emitSessionState();
|
emitSessionState();
|
||||||
return { scene: next };
|
return { scene: next };
|
||||||
});
|
});
|
||||||
registerHandler(ipcChannels.project.importMedia, async ({ sceneId }) => {
|
registerHandler(ipcChannels.project.importMedia, async ({ sceneId, filePaths: pathsFromDrop }) => {
|
||||||
const { canceled, filePaths } = await dialog.showOpenDialog({
|
let filePaths = pathsFromDrop ?? [];
|
||||||
properties: ['openFile', 'multiSelections'],
|
if (!pathsFromDrop) {
|
||||||
filters: [
|
const { canceled, filePaths: picked } = await dialog.showOpenDialog({
|
||||||
{
|
properties: ['openFile', 'multiSelections'],
|
||||||
name: 'Видео и аудио',
|
filters: [
|
||||||
extensions: ['mp4', 'webm', 'mov', 'mp3', 'wav', 'ogg', 'm4a', 'aac'],
|
{
|
||||||
},
|
name: 'Видео и аудио',
|
||||||
],
|
extensions: ['mp4', 'webm', 'mov', 'mp3', 'wav', 'ogg', 'm4a', 'aac'],
|
||||||
});
|
},
|
||||||
if (canceled || filePaths.length === 0) {
|
],
|
||||||
const project = projectStore.getOpenProject();
|
});
|
||||||
if (!project) throw new Error('No open project');
|
if (canceled || picked.length === 0) {
|
||||||
return { project, imported: [] };
|
const project = projectStore.getOpenProject();
|
||||||
|
if (!project) throw new Error('No open project');
|
||||||
|
return { project, imported: [] };
|
||||||
|
}
|
||||||
|
filePaths = picked;
|
||||||
}
|
}
|
||||||
const result = await projectStore.importMediaFiles(sceneId, filePaths);
|
const result = await projectStore.importMediaFiles(sceneId, filePaths);
|
||||||
emitSessionState();
|
emitSessionState();
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
registerHandler(ipcChannels.project.importCampaignAudio, async () => {
|
registerHandler(ipcChannels.project.importCampaignAudio, async ({ filePaths: pathsFromDrop }) => {
|
||||||
const { canceled, filePaths } = await dialog.showOpenDialog({
|
let filePaths = pathsFromDrop ?? [];
|
||||||
properties: ['openFile', 'multiSelections'],
|
if (!pathsFromDrop) {
|
||||||
filters: [
|
const { canceled, filePaths: picked } = await dialog.showOpenDialog({
|
||||||
{
|
properties: ['openFile', 'multiSelections'],
|
||||||
name: 'Аудио',
|
filters: [
|
||||||
extensions: ['mp3', 'wav', 'ogg', 'm4a', 'aac'],
|
{
|
||||||
},
|
name: 'Аудио',
|
||||||
],
|
extensions: ['mp3', 'wav', 'ogg', 'm4a', 'aac'],
|
||||||
});
|
},
|
||||||
if (canceled || filePaths.length === 0) {
|
],
|
||||||
const project = projectStore.getOpenProject();
|
});
|
||||||
if (!project) throw new Error('No open project');
|
if (canceled || picked.length === 0) {
|
||||||
return { canceled: true as const, project, imported: [] };
|
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);
|
const result = await projectStore.importCampaignAudioFiles(filePaths);
|
||||||
emitSessionState();
|
emitSessionState();
|
||||||
@@ -435,22 +443,26 @@ async function main() {
|
|||||||
emitSessionState();
|
emitSessionState();
|
||||||
return { project };
|
return { project };
|
||||||
});
|
});
|
||||||
registerHandler(ipcChannels.project.importScenePreview, async ({ sceneId }) => {
|
registerHandler(ipcChannels.project.importScenePreview, async ({ sceneId, filePath: pathFromDrop }) => {
|
||||||
const { canceled, filePaths } = await dialog.showOpenDialog({
|
let filePath = pathFromDrop;
|
||||||
properties: ['openFile'],
|
if (!filePath) {
|
||||||
filters: [
|
const { canceled, filePaths } = await dialog.showOpenDialog({
|
||||||
{
|
properties: ['openFile'],
|
||||||
name: 'Изображения и видео',
|
filters: [
|
||||||
extensions: ['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp', 'mp4', 'webm', 'mov'],
|
{
|
||||||
},
|
name: 'Изображения и видео',
|
||||||
],
|
extensions: ['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp', 'mp4', 'webm', 'mov'],
|
||||||
});
|
},
|
||||||
if (canceled || !filePaths[0]) {
|
],
|
||||||
const project = projectStore.getOpenProject();
|
});
|
||||||
if (!project) throw new Error('No open project');
|
if (canceled || !filePaths[0]) {
|
||||||
return { project, assetId: null, background: false };
|
const project = projectStore.getOpenProject();
|
||||||
|
if (!project) throw new Error('No open project');
|
||||||
|
return { project, assetId: null, background: false };
|
||||||
|
}
|
||||||
|
filePath = filePaths[0];
|
||||||
}
|
}
|
||||||
const result = await projectStore.importScenePreviewMedia(sceneId, filePaths[0]);
|
const result = await projectStore.importScenePreviewMedia(sceneId, filePath);
|
||||||
emitSessionState();
|
emitSessionState();
|
||||||
emitScenePreviewImportProgress({
|
emitScenePreviewImportProgress({
|
||||||
sceneId,
|
sceneId,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { contextBridge } from 'electron';
|
import { contextBridge, webUtils } from 'electron';
|
||||||
|
|
||||||
import type { IpcEventMap, IpcInvokeMap } from '../shared/ipc/contracts';
|
import type { IpcEventMap, IpcInvokeMap } from '../shared/ipc/contracts';
|
||||||
|
|
||||||
@@ -10,9 +10,14 @@ export type DndApi = {
|
|||||||
payload: IpcInvokeMap[K]['req'],
|
payload: IpcInvokeMap[K]['req'],
|
||||||
) => Promise<IpcInvokeMap[K]['res']>;
|
) => Promise<IpcInvokeMap[K]['res']>;
|
||||||
on: <K extends keyof IpcEventMap>(channel: K, listener: (payload: IpcEventMap[K]) => void) => () => void;
|
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);
|
contextBridge.exposeInMainWorld('dnd', api);
|
||||||
|
|
||||||
|
|||||||
@@ -661,6 +661,15 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
position: relative;
|
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 {
|
.previewFill {
|
||||||
@@ -754,6 +763,32 @@
|
|||||||
padding: 10px;
|
padding: 10px;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
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 {
|
.audioList {
|
||||||
|
|||||||
@@ -25,6 +25,12 @@ import { Button, Input } from '../shared/ui/controls';
|
|||||||
import { LayoutShell } from '../shared/ui/LayoutShell';
|
import { LayoutShell } from '../shared/ui/LayoutShell';
|
||||||
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
||||||
|
|
||||||
|
import {
|
||||||
|
filterAudioFilePaths,
|
||||||
|
pickFirstPreviewFilePath,
|
||||||
|
useFileDropZone,
|
||||||
|
} from './fileDrop';
|
||||||
|
|
||||||
import type { SceneImportResolution, StorylineImportMergeReport, StorylineSelection } from '../../shared/graph/storylineExportImport';
|
import type { SceneImportResolution, StorylineImportMergeReport, StorylineSelection } from '../../shared/graph/storylineExportImport';
|
||||||
import {
|
import {
|
||||||
buildSceneResolutionsForImport,
|
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.spacer18} />
|
||||||
<div className={styles.inspectorTitle}>{t('scenes.inspectorScene')}</div>
|
<div className={styles.inspectorTitle}>{t('scenes.inspectorScene')}</div>
|
||||||
@@ -834,6 +852,34 @@ export function EditorApp() {
|
|||||||
void actions.updateScene(sid, { previewRotationDeg })
|
void actions.updateScene(sid, { previewRotationDeg })
|
||||||
}
|
}
|
||||||
onUploadMedia={() => void actions.importMediaToScene(sid)}
|
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) =>
|
onSideStoryLineTitleChange={(graphNodeId, title) =>
|
||||||
void actions.updateSideStoryLineTitle(graphNodeId, title)
|
void actions.updateSideStoryLineTitle(graphNodeId, title)
|
||||||
}
|
}
|
||||||
@@ -1886,6 +1932,8 @@ type SceneInspectorProps = {
|
|||||||
onClearPreview: () => void;
|
onClearPreview: () => void;
|
||||||
onRotatePreview: (deg: 0 | 90 | 180 | 270) => void;
|
onRotatePreview: (deg: 0 | 90 | 180 | 270) => void;
|
||||||
onUploadMedia: () => void;
|
onUploadMedia: () => void;
|
||||||
|
onDropAudio: (filePaths: string[]) => void;
|
||||||
|
onDropPreview: (filePath: string) => void;
|
||||||
onSideStoryLineTitleChange: (graphNodeId: GraphNodeId, title: string) => void;
|
onSideStoryLineTitleChange: (graphNodeId: GraphNodeId, title: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1894,6 +1942,7 @@ type CampaignInspectorProps = {
|
|||||||
audioRefs: SceneAudioRef[];
|
audioRefs: SceneAudioRef[];
|
||||||
onAudioRefsChange: (next: SceneAudioRef[]) => void;
|
onAudioRefsChange: (next: SceneAudioRef[]) => void;
|
||||||
onUploadAudio: () => void;
|
onUploadAudio: () => void;
|
||||||
|
onDropAudio: (filePaths: string[]) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function CampaignInspector({
|
function CampaignInspector({
|
||||||
@@ -1901,13 +1950,27 @@ function CampaignInspector({
|
|||||||
audioRefs,
|
audioRefs,
|
||||||
onAudioRefsChange,
|
onAudioRefsChange,
|
||||||
onUploadAudio,
|
onUploadAudio,
|
||||||
|
onDropAudio,
|
||||||
}: CampaignInspectorProps) {
|
}: CampaignInspectorProps) {
|
||||||
const { t } = useEditorI18n();
|
const { t } = useEditorI18n();
|
||||||
const audioById = useMemo(() => new Map(audioRefs.map((a) => [a.assetId, a])), [audioRefs]);
|
const audioById = useMemo(() => new Map(audioRefs.map((a) => [a.assetId, a])), [audioRefs]);
|
||||||
|
const audioDrop = useFileDropZone({
|
||||||
|
onDropPaths: onDropAudio,
|
||||||
|
filterPaths: filterAudioFilePaths,
|
||||||
|
});
|
||||||
return (
|
return (
|
||||||
<div className={styles.sceneInspector}>
|
<div className={styles.sceneInspector}>
|
||||||
<div className={styles.labelSm}>{t('campaign.label')}</div>
|
<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 ? (
|
{mediaAssets.filter((a) => a.type === 'audio').length === 0 ? (
|
||||||
<div className={[styles.muted, styles.spanSm].join(' ')}>{t('campaign.noFiles')}</div>
|
<div className={[styles.muted, styles.spanSm].join(' ')}>{t('campaign.noFiles')}</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -1998,11 +2061,24 @@ function SceneInspector({
|
|||||||
onClearPreview,
|
onClearPreview,
|
||||||
onRotatePreview,
|
onRotatePreview,
|
||||||
onUploadMedia,
|
onUploadMedia,
|
||||||
|
onDropAudio,
|
||||||
|
onDropPreview,
|
||||||
onSideStoryLineTitleChange,
|
onSideStoryLineTitleChange,
|
||||||
}: SceneInspectorProps) {
|
}: SceneInspectorProps) {
|
||||||
const { t } = useEditorI18n();
|
const { t } = useEditorI18n();
|
||||||
const previewUrl = useAssetUrl(previewAssetId);
|
const previewUrl = useAssetUrl(previewAssetId);
|
||||||
const audioById = useMemo(() => new Map(audioRefs.map((a) => [a.assetId, a])), [audioRefs]);
|
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 (
|
return (
|
||||||
<div className={styles.sceneInspector}>
|
<div className={styles.sceneInspector}>
|
||||||
<div className={styles.labelSm}>{t('scene.title')}</div>
|
<div className={styles.labelSm}>{t('scene.title')}</div>
|
||||||
@@ -2032,7 +2108,16 @@ function SceneInspector({
|
|||||||
<div className={styles.spacer6} />
|
<div className={styles.spacer6} />
|
||||||
<div className={styles.labelSm}>{t('scene.preview')}</div>
|
<div className={styles.labelSm}>{t('scene.preview')}</div>
|
||||||
<div className={styles.hint}>{t('scene.previewHint')}</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' ? (
|
{previewUrl && previewAssetType === 'image' ? (
|
||||||
<div className={styles.previewFill}>
|
<div className={styles.previewFill}>
|
||||||
<RotatedImage
|
<RotatedImage
|
||||||
@@ -2107,7 +2192,16 @@ function SceneInspector({
|
|||||||
) : null}
|
) : null}
|
||||||
<div className={styles.spacer6} />
|
<div className={styles.spacer6} />
|
||||||
<div className={styles.labelSm}>{t('scene.audio')}</div>
|
<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 ? (
|
{mediaAssets.filter((a) => a.type === 'audio').length === 0 ? (
|
||||||
<div className={[styles.muted, styles.spanSm].join(' ')}>{t('campaign.noFiles')}</div>
|
<div className={[styles.muted, styles.spanSm].join(' ')}>{t('campaign.noFiles')}</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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.loop': 'Цикл',
|
||||||
'campaign.removeTitle': 'Убрать из кампании',
|
'campaign.removeTitle': 'Убрать из кампании',
|
||||||
'campaign.upload': 'Загрузить',
|
'campaign.upload': 'Загрузить',
|
||||||
|
'drop.hintAudio': 'Перетащите аудиофайлы сюда',
|
||||||
|
'drop.hintPreview': 'Перетащите изображение или видео',
|
||||||
|
|
||||||
'scene.title': 'НАЗВАНИЕ СЦЕНЫ',
|
'scene.title': 'НАЗВАНИЕ СЦЕНЫ',
|
||||||
'scene.description': 'ОПИСАНИЕ',
|
'scene.description': 'ОПИСАНИЕ',
|
||||||
@@ -689,6 +691,8 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
|||||||
'campaign.loop': 'Loop',
|
'campaign.loop': 'Loop',
|
||||||
'campaign.removeTitle': 'Remove from campaign',
|
'campaign.removeTitle': 'Remove from campaign',
|
||||||
'campaign.upload': 'Upload',
|
'campaign.upload': 'Upload',
|
||||||
|
'drop.hintAudio': 'Drop audio files here',
|
||||||
|
'drop.hintPreview': 'Drop an image or video',
|
||||||
|
|
||||||
'scene.title': 'SCENE TITLE',
|
'scene.title': 'SCENE TITLE',
|
||||||
'scene.description': 'DESCRIPTION',
|
'scene.description': 'DESCRIPTION',
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ type Actions = {
|
|||||||
createScene: () => Promise<void>;
|
createScene: () => Promise<void>;
|
||||||
selectScene: (id: SceneId) => Promise<void>;
|
selectScene: (id: SceneId) => Promise<void>;
|
||||||
importCampaignAudio: () => Promise<void>;
|
importCampaignAudio: () => Promise<void>;
|
||||||
|
importCampaignAudioFromPaths: (filePaths: string[]) => Promise<void>;
|
||||||
updateCampaignAudios: (next: Project['campaignAudios']) => Promise<void>;
|
updateCampaignAudios: (next: Project['campaignAudios']) => Promise<void>;
|
||||||
updateScene: (
|
updateScene: (
|
||||||
sceneId: SceneId,
|
sceneId: SceneId,
|
||||||
@@ -53,7 +54,12 @@ type Actions = {
|
|||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
updateConnections: (sceneId: SceneId, connections: SceneId[]) => Promise<void>;
|
updateConnections: (sceneId: SceneId, connections: SceneId[]) => Promise<void>;
|
||||||
importMediaToScene: (sceneId: SceneId) => Promise<void>;
|
importMediaToScene: (sceneId: SceneId) => Promise<void>;
|
||||||
|
importMediaToSceneFromPaths: (sceneId: SceneId, filePaths: string[]) => Promise<void>;
|
||||||
importScenePreview: (sceneId: SceneId) => Promise<{ assetId: AssetId | null; background: boolean }>;
|
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>;
|
clearScenePreview: (sceneId: SceneId) => Promise<void>;
|
||||||
updateSceneGraphNodePosition: (nodeId: GraphNodeId, x: number, y: number) => Promise<void>;
|
updateSceneGraphNodePosition: (nodeId: GraphNodeId, x: number, y: number) => Promise<void>;
|
||||||
addSceneGraphNode: (sceneId: SceneId, 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();
|
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 updateCampaignAudios = async (next: Project['campaignAudios']) => {
|
||||||
const res = await api.invoke(ipcChannels.project.updateCampaignAudios, { audios: next });
|
const res = await api.invoke(ipcChannels.project.updateCampaignAudios, { audios: next });
|
||||||
setState((s) => ({ ...s, project: res.project }));
|
setState((s) => ({ ...s, project: res.project }));
|
||||||
@@ -422,6 +436,14 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
|||||||
await refreshProjects();
|
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 importScenePreview = async (sceneId: SceneId) => {
|
||||||
const res = await api.invoke(ipcChannels.project.importScenePreview, { sceneId });
|
const res = await api.invoke(ipcChannels.project.importScenePreview, { sceneId });
|
||||||
setState((s) => ({ ...s, project: res.project }));
|
setState((s) => ({ ...s, project: res.project }));
|
||||||
@@ -438,6 +460,22 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
|||||||
return { assetId: res.assetId, background: res.background };
|
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 clearScenePreview = async (sceneId: SceneId) => {
|
||||||
const res = await api.invoke(ipcChannels.project.clearScenePreview, { sceneId });
|
const res = await api.invoke(ipcChannels.project.clearScenePreview, { sceneId });
|
||||||
setState((s) => ({ ...s, project: res.project }));
|
setState((s) => ({ ...s, project: res.project }));
|
||||||
@@ -650,11 +688,14 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
|||||||
createScene,
|
createScene,
|
||||||
selectScene,
|
selectScene,
|
||||||
importCampaignAudio,
|
importCampaignAudio,
|
||||||
|
importCampaignAudioFromPaths,
|
||||||
updateCampaignAudios,
|
updateCampaignAudios,
|
||||||
updateScene,
|
updateScene,
|
||||||
updateConnections,
|
updateConnections,
|
||||||
importMediaToScene,
|
importMediaToScene,
|
||||||
|
importMediaToSceneFromPaths,
|
||||||
importScenePreview,
|
importScenePreview,
|
||||||
|
importScenePreviewFromPath,
|
||||||
clearScenePreview,
|
clearScenePreview,
|
||||||
updateSceneGraphNodePosition,
|
updateSceneGraphNodePosition,
|
||||||
addSceneGraphNode,
|
addSceneGraphNode,
|
||||||
|
|||||||
@@ -219,11 +219,11 @@ export type IpcInvokeMap = {
|
|||||||
res: { currentGraphNodeId: GraphNodeId | null; currentSceneId: SceneId | null };
|
res: { currentGraphNodeId: GraphNodeId | null; currentSceneId: SceneId | null };
|
||||||
};
|
};
|
||||||
[ipcChannels.project.importMedia]: {
|
[ipcChannels.project.importMedia]: {
|
||||||
req: { sceneId: SceneId };
|
req: { sceneId: SceneId; filePaths?: string[] };
|
||||||
res: { project: Project; imported: MediaAsset[] };
|
res: { project: Project; imported: MediaAsset[] };
|
||||||
};
|
};
|
||||||
[ipcChannels.project.importCampaignAudio]: {
|
[ipcChannels.project.importCampaignAudio]: {
|
||||||
req: Record<string, never>;
|
req: { filePaths?: string[] };
|
||||||
res: { canceled: boolean; project: Project; imported: MediaAsset[] };
|
res: { canceled: boolean; project: Project; imported: MediaAsset[] };
|
||||||
};
|
};
|
||||||
[ipcChannels.project.updateCampaignAudios]: {
|
[ipcChannels.project.updateCampaignAudios]: {
|
||||||
@@ -231,7 +231,7 @@ export type IpcInvokeMap = {
|
|||||||
res: { project: Project };
|
res: { project: Project };
|
||||||
};
|
};
|
||||||
[ipcChannels.project.importScenePreview]: {
|
[ipcChannels.project.importScenePreview]: {
|
||||||
req: { sceneId: SceneId };
|
req: { sceneId: SceneId; filePath?: string };
|
||||||
res: { project: Project; assetId: AssetId | null; background: boolean };
|
res: { project: Project; assetId: AssetId | null; background: boolean };
|
||||||
};
|
};
|
||||||
[ipcChannels.project.clearScenePreview]: {
|
[ipcChannels.project.clearScenePreview]: {
|
||||||
|
|||||||
+1
-1
@@ -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();
|
const started = Date.now();
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const tick = () => {
|
const tick = () => {
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export default defineConfig(({ mode }) => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
|
host: '127.0.0.1',
|
||||||
port: 5173,
|
port: 5173,
|
||||||
strictPort: true,
|
strictPort: true,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user