diff --git a/app/main/index.ts b/app/main/index.ts index c781114..29be3da 100644 --- a/app/main/index.ts +++ b/app/main/index.ts @@ -392,39 +392,47 @@ async function main() { emitSessionState(); return { scene: next }; }); - registerHandler(ipcChannels.project.importMedia, async ({ sceneId }) => { - const { canceled, filePaths } = await dialog.showOpenDialog({ - properties: ['openFile', 'multiSelections'], - filters: [ - { - 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'); - return { project, imported: [] }; + registerHandler(ipcChannels.project.importMedia, async ({ sceneId, filePaths: pathsFromDrop }) => { + let filePaths = pathsFromDrop ?? []; + if (!pathsFromDrop) { + const { canceled, filePaths: picked } = await dialog.showOpenDialog({ + properties: ['openFile', 'multiSelections'], + filters: [ + { + name: 'Видео и аудио', + extensions: ['mp4', 'webm', 'mov', 'mp3', 'wav', 'ogg', 'm4a', 'aac'], + }, + ], + }); + 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({ - properties: ['openFile', 'multiSelections'], - filters: [ - { - name: 'Аудио', - extensions: ['mp3', 'wav', 'ogg', 'm4a', 'aac'], - }, - ], - }); - if (canceled || filePaths.length === 0) { - const project = projectStore.getOpenProject(); - if (!project) throw new Error('No open project'); - return { canceled: true as const, project, imported: [] }; + registerHandler(ipcChannels.project.importCampaignAudio, async ({ filePaths: pathsFromDrop }) => { + let filePaths = pathsFromDrop ?? []; + if (!pathsFromDrop) { + const { canceled, filePaths: picked } = await dialog.showOpenDialog({ + properties: ['openFile', 'multiSelections'], + filters: [ + { + name: 'Аудио', + extensions: ['mp3', 'wav', 'ogg', 'm4a', 'aac'], + }, + ], + }); + 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(); @@ -435,22 +443,26 @@ async function main() { emitSessionState(); return { project }; }); - registerHandler(ipcChannels.project.importScenePreview, async ({ sceneId }) => { - const { canceled, filePaths } = await dialog.showOpenDialog({ - properties: ['openFile'], - filters: [ - { - 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'); - return { project, assetId: null, background: false }; + registerHandler(ipcChannels.project.importScenePreview, async ({ sceneId, filePath: pathFromDrop }) => { + let filePath = pathFromDrop; + if (!filePath) { + const { canceled, filePaths } = await dialog.showOpenDialog({ + properties: ['openFile'], + filters: [ + { + 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'); + 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(); emitScenePreviewImportProgress({ sceneId, diff --git a/app/preload/index.ts b/app/preload/index.ts index 939c69b..b60fbfa 100644 --- a/app/preload/index.ts +++ b/app/preload/index.ts @@ -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; on: (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); diff --git a/app/renderer/editor/EditorApp.module.css b/app/renderer/editor/EditorApp.module.css index 448dbfc..494375d 100644 --- a/app/renderer/editor/EditorApp.module.css +++ b/app/renderer/editor/EditorApp.module.css @@ -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 { diff --git a/app/renderer/editor/EditorApp.tsx b/app/renderer/editor/EditorApp.tsx index 2ffe75d..67b484d 100644 --- a/app/renderer/editor/EditorApp.tsx +++ b/app/renderer/editor/EditorApp.tsx @@ -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), + }); + } + })(); + }} />
{t('scenes.inspectorScene')}
@@ -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 (
{t('campaign.label')}
-
+
+ {audioDrop.dragOver ? ( +
{t('drop.hintAudio')}
+ ) : null} {mediaAssets.filter((a) => a.type === 'audio').length === 0 ? (
{t('campaign.noFiles')}
) : ( @@ -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 (
{t('scene.title')}
@@ -2032,7 +2108,16 @@ function SceneInspector({
{t('scene.preview')}
{t('scene.previewHint')}
-
+
+ {previewDrop.dragOver ? ( +
{t('drop.hintPreview')}
+ ) : null} {previewUrl && previewAssetType === 'image' ? (
{t('scene.audio')}
-
+
+ {sceneAudioDrop.dragOver ? ( +
{t('drop.hintAudio')}
+ ) : null} {mediaAssets.filter((a) => a.type === 'audio').length === 0 ? (
{t('campaign.noFiles')}
) : ( diff --git a/app/renderer/editor/fileDrop.ts b/app/renderer/editor/fileDrop.ts new file mode 100644 index 0000000..17bad04 --- /dev/null +++ b/app/renderer/editor/fileDrop.ts @@ -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 }; +} diff --git a/app/renderer/editor/i18n/editorMessages.ts b/app/renderer/editor/i18n/editorMessages.ts index 0a139b6..a2de4e3 100644 --- a/app/renderer/editor/i18n/editorMessages.ts +++ b/app/renderer/editor/i18n/editorMessages.ts @@ -313,6 +313,8 @@ export const EDITOR_MESSAGES: Record> = { 'campaign.loop': 'Цикл', 'campaign.removeTitle': 'Убрать из кампании', 'campaign.upload': 'Загрузить', + 'drop.hintAudio': 'Перетащите аудиофайлы сюда', + 'drop.hintPreview': 'Перетащите изображение или видео', 'scene.title': 'НАЗВАНИЕ СЦЕНЫ', 'scene.description': 'ОПИСАНИЕ', @@ -689,6 +691,8 @@ export const EDITOR_MESSAGES: Record> = { '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', diff --git a/app/renderer/editor/state/projectState.ts b/app/renderer/editor/state/projectState.ts index befd654..95b73fe 100644 --- a/app/renderer/editor/state/projectState.ts +++ b/app/renderer/editor/state/projectState.ts @@ -34,6 +34,7 @@ type Actions = { createScene: () => Promise; selectScene: (id: SceneId) => Promise; importCampaignAudio: () => Promise; + importCampaignAudioFromPaths: (filePaths: string[]) => Promise; updateCampaignAudios: (next: Project['campaignAudios']) => Promise; updateScene: ( sceneId: SceneId, @@ -53,7 +54,12 @@ type Actions = { ) => Promise; updateConnections: (sceneId: SceneId, connections: SceneId[]) => Promise; importMediaToScene: (sceneId: SceneId) => Promise; + importMediaToSceneFromPaths: (sceneId: SceneId, filePaths: string[]) => Promise; importScenePreview: (sceneId: SceneId) => Promise<{ assetId: AssetId | null; background: boolean }>; + importScenePreviewFromPath: ( + sceneId: SceneId, + filePath: string, + ) => Promise<{ assetId: AssetId | null; background: boolean }>; clearScenePreview: (sceneId: SceneId) => Promise; updateSceneGraphNodePosition: (nodeId: GraphNodeId, x: number, y: number) => Promise; addSceneGraphNode: (sceneId: SceneId, x: number, y: number) => Promise; @@ -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, diff --git a/app/shared/ipc/contracts.ts b/app/shared/ipc/contracts.ts index 809371f..692a950 100644 --- a/app/shared/ipc/contracts.ts +++ b/app/shared/ipc/contracts.ts @@ -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; + 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]: { diff --git a/scripts/dev.mjs b/scripts/dev.mjs index d737de5..97677cf 100644 --- a/scripts/dev.mjs +++ b/scripts/dev.mjs @@ -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 = () => { diff --git a/vite.config.ts b/vite.config.ts index d5fbe62..532dec8 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -57,6 +57,7 @@ export default defineConfig(({ mode }) => { }, }, server: { + host: '127.0.0.1', port: 5173, strictPort: true, },