import React, { useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { ipcChannels } from '../../shared/ipc/contracts'; import type { AssetId, MediaAsset, ProjectId, SceneAudioRef, SceneId } from '../../shared/types'; import { AppLogo } from '../shared/branding/AppLogo'; import { getDndApi } from '../shared/dndApi'; import { RotatedImage } from '../shared/RotatedImage'; import { Button, Input } from '../shared/ui/controls'; import { LayoutShell } from '../shared/ui/LayoutShell'; import { useAssetUrl } from '../shared/useAssetImageUrl'; import styles from './EditorApp.module.css'; import { DND_SCENE_ID_MIME, SceneGraph } from './graph/SceneGraph'; import { useProjectState } from './state/projectState'; type SceneCard = { id: SceneId; title: string; active: boolean; previewAssetId: AssetId | null; previewAssetType: 'image' | 'video' | null; previewVideoAutostart: boolean; previewRotationDeg: 0 | 90 | 180 | 270; }; export function EditorApp() { const [appVersionText, setAppVersionText] = useState(null); const [query, setQuery] = useState(''); const [fileMenuOpen, setFileMenuOpen] = useState(false); const [projectMenuOpen, setProjectMenuOpen] = useState(false); const [renameOpen, setRenameOpen] = useState(false); const [exportModalOpen, setExportModalOpen] = useState(false); const [state, actions] = useProjectState(); const fileMenuBtnRef = useRef(null); const projectMenuBtnRef = useRef(null); const [fileMenuPos, setFileMenuPos] = useState<{ left: number; top: number } | null>(null); const [projectMenuPos, setProjectMenuPos] = useState<{ left: number; top: number } | null>(null); const scenes = useMemo(() => { const p = state.project; if (!p) return []; return Object.values(p.scenes).map((s) => ({ id: s.id, title: s.title, active: s.id === state.selectedSceneId, previewAssetId: s.previewAssetId, previewAssetType: s.previewAssetType, previewVideoAutostart: s.previewVideoAutostart, previewRotationDeg: s.previewRotationDeg, })); }, [state.project, state.selectedSceneId]); const filtered = useMemo( () => scenes.filter((s) => s.title.toLowerCase().includes(query.trim().toLowerCase())), [query, scenes], ); const sceneMediaAssets = useMemo(() => { const p = state.project; const sid = state.selectedSceneId; if (!p || !sid) return []; const scene = p.scenes[sid]; if (!scene) return []; const ids = [...scene.media.videos, ...scene.media.audios.map((a) => a.assetId)]; return ids.map((id) => p.assets[id]).filter((a): a is MediaAsset => Boolean(a)); }, [state.project, state.selectedSceneId]); const sceneAudioRefs = useMemo(() => { const p = state.project; const sid = state.selectedSceneId; if (!p || !sid) return []; const scene = p.scenes[sid]; if (!scene) return []; return scene.media.audios; }, [state.project, state.selectedSceneId]); const graphStartSceneId = useMemo(() => { const p = state.project; if (!p) return null; const gn = p.sceneGraphNodes.find((n) => n.isStartScene); return gn?.sceneId ?? null; }, [state.project]); const graphStartGraphNodeId = useMemo(() => { const p = state.project; if (!p) return null; const gn = p.sceneGraphNodes.find((n) => n.isStartScene); return gn?.id ?? null; }, [state.project]); const currentProjectName = state.project?.meta.name ?? ''; const currentFileBaseName = state.project?.meta.fileBaseName ?? ''; const existingProjectNames = useMemo(() => state.projects.map((p) => p.name), [state.projects]); const existingFileBaseNames = useMemo(() => { return state.projects.map((p) => p.fileName.replace(/\.dnd\.zip$/iu, '')); }, [state.projects]); useEffect(() => { if (!fileMenuOpen) return; const r = fileMenuBtnRef.current?.getBoundingClientRect() ?? null; queueMicrotask(() => { if (r) { setFileMenuPos({ left: r.left, top: r.bottom + 10 }); } else { setFileMenuPos(null); } }); const onDown = (e: MouseEvent) => { const t = e.target as HTMLElement | null; if (!t) return; if (t.closest('[data-filemenu-root="1"]')) return; setFileMenuOpen(false); }; window.addEventListener('mousedown', onDown); return () => window.removeEventListener('mousedown', onDown); }, [fileMenuOpen]); useEffect(() => { if (!projectMenuOpen) return; const r = projectMenuBtnRef.current?.getBoundingClientRect() ?? null; queueMicrotask(() => { if (r) { setProjectMenuPos({ left: r.left, top: r.bottom + 10 }); } else { setProjectMenuPos(null); } }); const onDown = (e: MouseEvent) => { const t = e.target as HTMLElement | null; if (!t) return; if (t.closest('[data-projectmenu-root="1"]')) return; setProjectMenuOpen(false); }; window.addEventListener('mousedown', onDown); return () => window.removeEventListener('mousedown', onDown); }, [projectMenuOpen]); useEffect(() => { void (async () => { try { const r = await getDndApi().invoke(ipcChannels.app.getVersion, {}); const label = r.buildNumber ? `v${r.version} · ${r.buildNumber}` : `v${r.version}`; setAppVersionText(label); } catch { setAppVersionText(null); } })(); }, []); const exportModalInitialProjectId = state.project?.id ?? state.projects[0]?.id ?? null; return ( <>
{state.project ? ( ) : null}
{appVersionText ? (
{appVersionText}
) : null}
{state.project ? ( ) : null}
} left={
{state.project ? ( <>
{filtered.map((s) => ( void actions.selectScene(s.id)} onDeleteScene={(id) => void actions.deleteScene(id)} /> ))}
) : ( )}
} center={
{state.project ? ( void actions.selectScene(id)} onConnect={(sourceGn, targetGn) => void actions.addSceneGraphEdge(sourceGn, targetGn)} onDisconnect={(edgeId) => void actions.removeSceneGraphEdge(edgeId)} onNodePositionCommit={(nodeId, x, y) => void actions.updateSceneGraphNodePosition(nodeId, x, y) } onRemoveGraphNodes={(ids) => { void Promise.all(ids.map((id) => actions.removeSceneGraphNode(id))); }} onRemoveGraphNode={(id) => void actions.removeSceneGraphNode(id)} onSetGraphNodeStart={(graphNodeId) => void actions.setSceneGraphNodeStart(graphNodeId)} onDropSceneFromList={(sceneId, x, y) => void actions.addSceneGraphNode(sceneId, x, y)} /> ) : (
)}
} right={
Свойства сцены
{state.project && state.selectedSceneId ? ( (() => { const proj = state.project; const sid = state.selectedSceneId; const sc = proj.scenes[sid]; return ( void actions.updateScene(sid, { media: { audios: next } })} onPreviewVideoAutostartChange={(next) => void actions.updateScene(sid, { previewVideoAutostart: next }) } onTitleChange={(title) => void actions.updateScene(sid, { title })} onDescriptionChange={(description) => void actions.updateScene(sid, { description })} onImportPreview={() => void actions.importScenePreview(sid)} onClearPreview={() => void actions.clearScenePreview(sid)} onRotatePreview={(previewRotationDeg) => void actions.updateScene(sid, { previewRotationDeg }) } onUploadMedia={() => void actions.importMediaToScene(sid)} /> ); })() ) : (
Откройте проект, чтобы редактировать сцену.
)}
} /> {projectMenuOpen && projectMenuPos ? createPortal(
, document.body, ) : null} {fileMenuOpen && fileMenuPos && state.project ? createPortal(
, document.body, ) : null} {state.project ? ( setRenameOpen(false)} onSave={async (name, fileBaseName) => { await actions.renameProject(name, fileBaseName); }} /> ) : null} setExportModalOpen(false)} onExport={async (projectId) => { await actions.exportProject(projectId); }} /> ); } type ExportProjectModalProps = { open: boolean; projects: { id: ProjectId; name: string; fileName: string }[]; initialProjectId: ProjectId | null; onClose: () => void; onExport: (projectId: ProjectId) => Promise; }; function ExportProjectModal({ open, projects, initialProjectId, onClose, onExport, }: ExportProjectModalProps) { const [projectId, setProjectId] = useState(initialProjectId); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); useEffect(() => { if (!open) return; setProjectId(initialProjectId); setSaving(false); setError(null); }, [initialProjectId, open]); useEffect(() => { if (!open) return; const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [onClose, open]); if (!open) return null; const canExport = projectId !== null && projects.some((p) => p.id === projectId); return createPortal( <>
ПРОЕКТ
Далее откроется окно сохранения: укажите имя и папку для файла .dnd.zip — будет создана копия архива проекта.
{error ?
{error}
: null}
, document.body, ); } function isValidFileBaseName(input: string): boolean { const trimmed = input.trim(); if (trimmed.length < 3) return false; return !/[<>:"/\\|?*]/gu.test(trimmed); } function normalizeName(input: string): string { return input.trim().toLowerCase(); } type RenameProjectModalProps = { open: boolean; projectNameInitial: string; fileBaseNameInitial: string; existingProjectNames: string[]; existingFileBaseNames: string[]; onClose: () => void; onSave: (projectName: string, fileBaseName: string) => Promise; }; function RenameProjectModal({ open, projectNameInitial, fileBaseNameInitial, existingProjectNames, existingFileBaseNames, onClose, onSave, }: RenameProjectModalProps) { const [projectName, setProjectName] = useState(projectNameInitial); const [fileBaseName, setFileBaseName] = useState(fileBaseNameInitial); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); useEffect(() => { if (!open) return; setProjectName(projectNameInitial); setFileBaseName(fileBaseNameInitial); setSaving(false); setError(null); }, [fileBaseNameInitial, open, projectNameInitial]); useEffect(() => { if (!open) return; const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [onClose, open]); const trimmedProjectName = projectName.trim(); const trimmedFileBase = fileBaseName.trim(); const projectNameOk = trimmedProjectName.length >= 3; const fileNameOk = isValidFileBaseName(trimmedFileBase); const projectNameDup = normalizeName(trimmedProjectName) !== normalizeName(projectNameInitial) && existingProjectNames.some((n) => normalizeName(n) === normalizeName(trimmedProjectName)); const fileNameDup = normalizeName(trimmedFileBase) !== normalizeName(fileBaseNameInitial) && existingFileBaseNames.some((n) => normalizeName(n) === normalizeName(trimmedFileBase)); const canSave = projectNameOk && fileNameOk && !projectNameDup && !fileNameDup && !saving; if (!open) return null; return createPortal( <>
НАЗВАНИЕ ПРОЕКТА
{!projectNameOk ?
Минимум 3 символа.
: null} {projectNameDup ? (
Проект с таким названием уже существует.
) : null}
НАЗВАНИЕ ФАЙЛА ПРОЕКТА
.dnd.zip
{!fileNameOk ? (
Минимум 3 символа, без символов {'<>:"/\\|?*'}
) : null} {fileNameDup ? (
Файл проекта с таким названием уже существует.
) : null}
{error ?
{error}
: null}
, document.body, ); } type ProjectPickerProps = { projects: { id: ProjectId; name: string; updatedAt: string }[]; onCreate: (name: string) => Promise; onOpen: (id: ProjectId) => Promise; onDelete: (id: ProjectId) => Promise; }; function ProjectPicker({ projects, onCreate, onOpen, onDelete }: ProjectPickerProps) { const [name, setName] = useState('Моя кампания'); const [rowMenuFor, setRowMenuFor] = useState(null); const [rowMenuPos, setRowMenuPos] = useState<{ left: number; top: number } | null>(null); useEffect(() => { if (!rowMenuFor) return; const onDown = (e: MouseEvent) => { const t = e.target as HTMLElement | null; if (!t) return; if (t.closest('[data-project-row-menu-root="1"]')) return; setRowMenuFor(null); setRowMenuPos(null); }; window.addEventListener('mousedown', onDown); return () => window.removeEventListener('mousedown', onDown); }, [rowMenuFor]); return (
Проекты
СУЩЕСТВУЮЩИЕ
{projects.map((p) => (
void onOpen(p.id)} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') void onOpen(p.id); }} >
{p.name}
{new Date(p.updatedAt).toLocaleString('ru-RU')}
))} {projects.length === 0 ?
Пока нет проектов.
: null}
{rowMenuFor && rowMenuPos ? createPortal(
, document.body, ) : null}
); } type SceneInspectorProps = { title: string; description: string; previewAssetId: AssetId | null; previewAssetType: 'image' | 'video' | null; previewVideoAutostart: boolean; previewRotationDeg: 0 | 90 | 180 | 270; mediaAssets: MediaAsset[]; audioRefs: SceneAudioRef[]; onAudioRefsChange: (next: SceneAudioRef[]) => void; onPreviewVideoAutostartChange: (next: boolean) => void; onTitleChange: (v: string) => void; onDescriptionChange: (v: string) => void; onImportPreview: () => void; onClearPreview: () => void; onRotatePreview: (deg: 0 | 90 | 180 | 270) => void; onUploadMedia: () => void; }; function SceneInspector({ title, description, previewAssetId, previewAssetType, previewVideoAutostart, previewRotationDeg, mediaAssets, audioRefs, onAudioRefsChange, onPreviewVideoAutostartChange, onTitleChange, onDescriptionChange, onImportPreview, onClearPreview, onRotatePreview, onUploadMedia, }: SceneInspectorProps) { const previewUrl = useAssetUrl(previewAssetId); const audioById = useMemo(() => new Map(audioRefs.map((a) => [a.assetId, a])), [audioRefs]); return (
НАЗВАНИЕ СЦЕНЫ
ОПИСАНИЕ