import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { ipcChannels } from '../../shared/ipc/contracts'; import { EULA_CURRENT_VERSION } from '../../shared/license/eulaVersion'; import type { LicenseSnapshot } from '../../shared/license/licenseSnapshot'; import type { AssetId, MediaAsset, Project, 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 { buildNextSceneCardById } from './graph/sceneCardById'; import { DND_SCENE_ID_MIME, SceneGraph, type SceneGraphSceneCard, type SceneGraphUiStrings, } from './graph/SceneGraph'; import { useEditorI18n } from './i18n/EditorI18nContext'; import { EulaModal, LicenseAboutModal, LicenseTokenModal } from './license/EditorLicenseModals'; import type { ProjectNoticeCode } from './state/projectState'; import { useProjectState } from './state/projectState'; type SceneCard = { id: SceneId; title: string; active: boolean; previewAssetId: AssetId | null; previewThumbAssetId: AssetId | null; previewAssetType: 'image' | 'video' | null; previewVideoAutostart: boolean; previewRotationDeg: 0 | 90 | 180 | 270; }; /** Лёгкая карта сцен для графа: стабильные ссылки на объекты, пока не меняются поля карточки. */ function useStableSceneCardById(project: Project | null): Record { const recordRef = useRef>({}); const projectIdRef = useRef(null); /* Ref cache: avoid new Record / per-scene object identities when only irrelevant Scene fields change * (e.g. description). react-hooks/refs disallows ref access during render; this is intentional. */ /* eslint-disable react-hooks/refs -- stable graph input identity */ return useMemo(() => { if (!project) { recordRef.current = {}; projectIdRef.current = null; return {}; } if (projectIdRef.current !== project.id) { recordRef.current = {}; projectIdRef.current = project.id; } const prevRecord = recordRef.current; const nextMap = buildNextSceneCardById(prevRecord, project); recordRef.current = nextMap; return nextMap; }, [project]); /* eslint-enable react-hooks/refs */ } export function EditorApp() { const { t, locale, setLocale } = useEditorI18n(); const [appVersionText, setAppVersionText] = useState(null); const [query, setQuery] = useState(''); const [fileMenuOpen, setFileMenuOpen] = useState(false); const [projectMenuOpen, setProjectMenuOpen] = useState(false); const [settingsMenuOpen, setSettingsMenuOpen] = useState(false); const [settingsLangSubOpen, setSettingsLangSubOpen] = useState(false); const [renameOpen, setRenameOpen] = useState(false); const [exportModalOpen, setExportModalOpen] = useState(false); const [previewBusy, setPreviewBusy] = useState(false); const [presentationOpen, setPresentationOpen] = useState(false); const [licenseSnap, setLicenseSnap] = useState(null); const [licenseKeyModalOpen, setLicenseKeyModalOpen] = useState(false); const [eulaModalOpen, setEulaModalOpen] = useState(false); const [aboutLicenseOpen, setAboutLicenseOpen] = useState(false); const [openKeyAfterEula, setOpenKeyAfterEula] = useState(false); const licenseActive = licenseSnap?.active === true; const [appNotice, setAppNotice] = useState<{ title?: string; message: string } | null>(null); const onProjectNotice = useCallback( (code: ProjectNoticeCode) => { const handlers: Record void> = { campaign_audio_empty: () => setAppNotice({ title: t('common.message'), message: t('notice.campaignAudioEmpty') }), }; handlers[code](); }, [t], ); const [state, actions] = useProjectState(licenseActive, { onNotice: onProjectNotice }); const sceneCardById = useStableSceneCardById(state.project); const graphUi = useMemo( () => ({ badgeStart: t('graph.badgeStart'), untitled: t('graph.untitled'), videoBadge: t('graph.videoBadge'), audioBadge: t('graph.audioBadge'), loop: t('graph.loop'), autoplay: t('graph.autoplay'), previewAutostart: t('graph.previewAutostart'), videoLoop: t('graph.videoLoop'), zoomBar: t('graph.zoomBar'), zoomIn: t('graph.zoomIn'), zoomOut: t('graph.zoomOut'), fitAll: t('graph.fitAll'), closeMenu: t('common.closeMenu'), startScene: t('graph.startScene'), unsetStartScene: t('graph.unsetStartScene'), delete: t('common.delete'), }), [t], ); const fileMenuBtnRef = useRef(null); const projectMenuBtnRef = useRef(null); const settingsMenuBtnRef = useRef(null); const [fileMenuPos, setFileMenuPos] = useState<{ left: number; top: number } | null>(null); const [projectMenuPos, setProjectMenuPos] = useState<{ left: number; top: number } | null>(null); const [settingsMenuPos, setSettingsMenuPos] = useState<{ left: number; top: number } | null>(null); const scenes = useMemo(() => { const p = state.project; if (!p) return []; const createdAtSortKey = (sceneId: string): number => { // sceneId создаётся как `${prefix}_${rand}_${Date.now().toString(16)}` const last = sceneId.split('_').at(-1) ?? ''; const n = Number.parseInt(last, 16); return Number.isFinite(n) ? n : 0; }; return Object.values(p.scenes) .map((s) => ({ id: s.id, title: s.title, active: s.id === state.selectedSceneId, previewAssetId: s.previewAssetId, previewThumbAssetId: s.previewThumbAssetId, previewAssetType: s.previewAssetType, previewVideoAutostart: s.previewVideoAutostart, previewRotationDeg: s.previewRotationDeg, })) .sort((a, b) => createdAtSortKey(b.id) - createdAtSortKey(a.id)); }, [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 campaignAudioRefs = useMemo(() => { return state.project?.campaignAudios ?? []; }, [state.project]); const campaignAudioAssets = useMemo(() => { const p = state.project; if (!p) return []; return campaignAudioRefs.map((r) => p.assets[r.assetId]).filter((a): a is MediaAsset => Boolean(a)); }, [campaignAudioRefs, state.project]); 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(() => { if (!settingsMenuOpen) return; const r = settingsMenuBtnRef.current?.getBoundingClientRect() ?? null; queueMicrotask(() => { if (r) { setSettingsMenuPos({ left: r.left, top: r.bottom + 10 }); } else { setSettingsMenuPos(null); } }); const onDown = (e: MouseEvent) => { const t = e.target as HTMLElement | null; if (!t) return; if (t.closest('[data-settingsmenu-root="1"]')) return; setSettingsMenuOpen(false); }; window.addEventListener('mousedown', onDown); return () => window.removeEventListener('mousedown', onDown); }, [settingsMenuOpen]); useEffect(() => { if (!settingsMenuOpen) setSettingsLangSubOpen(false); }, [settingsMenuOpen]); useEffect(() => { let off: (() => void) | null = null; void (async () => { try { const snap = await getDndApi().invoke(ipcChannels.windows.getMultiWindowState, {}); setPresentationOpen(snap.open); } catch { // ignore } off = getDndApi().on(ipcChannels.windows.multiWindowStateChanged, ({ open }) => { setPresentationOpen(open); }); })(); return () => { off?.(); }; }, []); const reloadLicense = useCallback(() => { void (async () => { try { const s = await getDndApi().invoke(ipcChannels.license.getStatus, {}); setLicenseSnap(s); } catch { setLicenseSnap(null); } })(); }, []); useEffect(() => { reloadLicense(); const unsub = getDndApi().on(ipcChannels.license.statusChanged, () => { reloadLicense(); }); return unsub; }, [reloadLicense]); 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; const bodyOverlay = licenseSnap === null ? (
{t('license.checkingTitle')}
{t('license.checkingWait')}
) : !licenseSnap.active ? (
{t('license.requiredTitle')}
{t('license.requiredHint')}
) : undefined; return ( <> {presentationOpen ? createPortal(
{t('presentation.title')}
{t('presentation.body')}
, document.body, ) : null} {state.zipProgress ? createPortal(
{state.zipProgress.kind === 'import' ? t('zip.importTitle') : t('zip.exportTitle')}
{state.zipProgress.detail ?? state.zipProgress.stage}
{state.zipProgress.percent}%
, document.body, ) : null}
{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 ? ( <>
{t('scenes.inspectorGame')}
void actions.updateCampaignAudios(next)} onUploadAudio={() => { void (async () => { try { await actions.importCampaignAudio(); } catch (e) { setAppNotice({ title: t('common.error'), message: e instanceof Error ? e.message : String(e), }); } })(); }} />
{t('scenes.inspectorScene')}
{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={() => { setPreviewBusy(true); void (async () => { try { await actions.importScenePreview(sid); } catch (e) { setAppNotice({ title: t('common.error'), message: e instanceof Error ? e.message : String(e), }); } finally { setPreviewBusy(false); } })(); }} onClearPreview={() => void actions.clearScenePreview(sid)} onRotatePreview={(previewRotationDeg) => void actions.updateScene(sid, { previewRotationDeg }) } onUploadMedia={() => void actions.importMediaToScene(sid)} /> ); })() ) : (
{t('scenes.selectHint')}
)} ) : (
{t('scenes.openProjectHint')}
)}
} /> {settingsMenuOpen && settingsMenuPos ? createPortal(
{settingsLangSubOpen ? (
) : null}
, document.body, ) : null} setLicenseKeyModalOpen(false)} onSaved={() => { reloadLicense(); }} /> { setEulaModalOpen(false); setOpenKeyAfterEula(false); }} onAccepted={() => { if (openKeyAfterEula) { setLicenseKeyModalOpen(true); } setOpenKeyAfterEula(false); }} /> setAboutLicenseOpen(false)} snapshot={licenseSnap} /> {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); }} /> setAppNotice(null)} /> ); } 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 { t } = useEditorI18n(); 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( <>
{t('export.project')}
{t('export.hint')}
{error ?
{error}
: null}
, document.body, ); } type SimpleMessageModalProps = { open: boolean; title?: string; message: string; onClose: () => void; }; function SimpleMessageModal({ open, title, message, onClose }: SimpleMessageModalProps) { const { t } = useEditorI18n(); 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; return createPortal( <>
{message}
, document.body, ); } type ConfirmDeleteProjectModalProps = { open: boolean; projectName: string; busy: boolean; onCancel: () => void; onConfirm: () => void | Promise; }; function ConfirmDeleteProjectModal({ open, projectName, busy, onCancel, onConfirm, }: ConfirmDeleteProjectModalProps) { const { t } = useEditorI18n(); useEffect(() => { if (!open) return; const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape' && !busy) onCancel(); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [busy, onCancel, open]); if (!open) return null; return createPortal( <>
{t('confirmDelete.body', { name: projectName })}
, 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 { t } = useEditorI18n(); 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( <>
{t('rename.projectName')}
{!projectNameOk ?
{t('rename.projectMin')}
: null} {projectNameDup ?
{t('rename.projectDup')}
: null}
{t('rename.fileName')}
.dnd.zip
{!fileNameOk ?
{t('rename.fileInvalid')}
: null} {fileNameDup ?
{t('rename.fileDup')}
: null}
{error ?
{error}
: null}
, document.body, ); } type ProjectPickerProps = { projects: { id: ProjectId; name: string; updatedAt: string }[]; licenseActive: boolean; onCreate: (name: string) => Promise; onOpen: (id: ProjectId) => Promise; onDelete: (id: ProjectId) => Promise; }; function ProjectPicker({ projects, licenseActive, onCreate, onOpen, onDelete }: ProjectPickerProps) { const { t, locale } = useEditorI18n(); const [name, setName] = useState(() => t('picker.defaultName')); const [rowMenuFor, setRowMenuFor] = useState(null); const [rowMenuPos, setRowMenuPos] = useState<{ left: number; top: number } | null>(null); const [pendingDelete, setPendingDelete] = useState<{ id: ProjectId; name: string } | null>(null); const [deleteSubmitting, setDeleteSubmitting] = useState(false); const [deleteError, setDeleteError] = useState(null); useEffect(() => { if (!rowMenuFor) return; const onDown = (e: MouseEvent) => { const tgt = e.target as HTMLElement | null; if (!tgt) return; if (tgt.closest('[data-project-row-menu-root="1"]')) return; setRowMenuFor(null); setRowMenuPos(null); }; window.addEventListener('mousedown', onDown); return () => window.removeEventListener('mousedown', onDown); }, [rowMenuFor]); return (
{t('picker.title')}
{t('picker.existing')}
{!licenseActive && projects.length > 0 ? ( <>
{t('picker.lockedHint')}
) : null}
{projects.map((p) => (
{ if (!licenseActive) return; void onOpen(p.id); }} role="button" tabIndex={0} title={!licenseActive ? t('picker.openDisabled') : undefined} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { if (!licenseActive) return; void onOpen(p.id); } }} >
{p.name}
{new Date(p.updatedAt).toLocaleString(locale === 'en' ? 'en-US' : 'ru-RU')}
))} {projects.length === 0 ?
{t('picker.empty')}
: null}
{rowMenuFor && rowMenuPos ? createPortal(
, document.body, ) : null} { if (deleteSubmitting) return; setPendingDelete(null); }} onConfirm={async () => { if (!pendingDelete) return; const { id } = pendingDelete; setDeleteSubmitting(true); try { await onDelete(id); setPendingDelete(null); } catch (e) { setDeleteError(e instanceof Error ? e.message : String(e)); setPendingDelete(null); } finally { setDeleteSubmitting(false); } }} /> setDeleteError(null)} />
); } type SceneInspectorProps = { title: string; description: string; previewAssetId: AssetId | null; previewAssetType: 'image' | 'video' | null; previewVideoAutostart: boolean; previewRotationDeg: 0 | 90 | 180 | 270; previewBusy: boolean; 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; }; type CampaignInspectorProps = { mediaAssets: MediaAsset[]; audioRefs: SceneAudioRef[]; onAudioRefsChange: (next: SceneAudioRef[]) => void; onUploadAudio: () => void; }; function CampaignInspector({ mediaAssets, audioRefs, onAudioRefsChange, onUploadAudio, }: CampaignInspectorProps) { const { t } = useEditorI18n(); const audioById = useMemo(() => new Map(audioRefs.map((a) => [a.assetId, a])), [audioRefs]); return (
{t('campaign.label')}
{mediaAssets.filter((a) => a.type === 'audio').length === 0 ? (
{t('campaign.noFiles')}
) : (
{mediaAssets .filter((a) => a.type === 'audio') .map((a) => (
{a.originalName}
))}
)}
); } function SceneInspector({ title, description, previewAssetId, previewAssetType, previewVideoAutostart, previewRotationDeg, previewBusy, mediaAssets, audioRefs, onAudioRefsChange, onPreviewVideoAutostartChange, onTitleChange, onDescriptionChange, onImportPreview, onClearPreview, onRotatePreview, onUploadMedia, }: SceneInspectorProps) { const { t } = useEditorI18n(); const previewUrl = useAssetUrl(previewAssetId); const audioById = useMemo(() => new Map(audioRefs.map((a) => [a.assetId, a])), [audioRefs]); return (
{t('scene.title')}
{t('scene.description')}