import React, { startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { moveSceneInListOrder, reconcileSceneListOrder } from '../../shared/graph/sceneListOrder'; import type { SceneImportResolution, StorylineImportMergeReport, StorylineSelection, } from '../../shared/graph/storylineExportImport'; import { ipcChannels, type UpdaterCheckResponse, type UpdaterProgressEvent, } from '../../shared/ipc/contracts'; import { EULA_CURRENT_VERSION } from '../../shared/license/eulaVersion'; import type { LicenseSnapshot } from '../../shared/license/licenseSnapshot'; import { PROJECT_ZIP_EXTENSION } from '../../shared/project/projectZipExtension'; import type { AssetId, GraphNodeId, 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 { AppAboutModal, InstructionsModal } from './about/EditorAboutModals'; import styles from './EditorApp.module.css'; import { filterAudioFilePaths, partitionSceneMediaDrops, pickFirstPreviewFilePath, sceneTitleFromMediaPath, useFileDropZone, } from './fileDrop'; import { buildNextSceneCardById } from './graph/sceneCardById'; import { DND_SCENE_ID_MIME, SceneGraph, type SceneGraphSceneCard, type SceneGraphUiStrings, } from './graph/SceneGraph'; import type { HelpSectionId } from './help/helpSections'; import { useEditorI18n } from './i18n/EditorI18nContext'; import { EulaModal, LicenseAboutModal, LicenseTokenModal } from './license/EditorLicenseModals'; import { isSceneDescriptionEmpty, sanitizeSceneDescriptionHtml } from './sceneDescriptionHtml'; import { SceneDescriptionModal } from './SceneDescriptionModal'; import type { ProjectNoticeCode } from './state/projectState'; import { useProjectState } from './state/projectState'; import { buildSceneResolutionsForImport, computeImportConflicts, ExportProjectModal, ImportReportModal, ImportSourceModal, ImportStorylinesModal, SceneConflictModal, useStorylineLabels, type ImportPeekResult, type ImportSourceSelection, } from './StorylineTransferModals'; 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 [aboutMenuOpen, setAboutMenuOpen] = useState(false); const [appAboutOpen, setAppAboutOpen] = useState(false); const [instructionsOpen, setInstructionsOpen] = useState(false); const [instructionsSection, setInstructionsSection] = useState('overview'); const [renameOpen, setRenameOpen] = useState(false); const [exportModalOpen, setExportModalOpen] = useState(false); const [importSourceOpen, setImportSourceOpen] = useState(false); const [importPeek, setImportPeek] = useState(null); const [importStorylinesOpen, setImportStorylinesOpen] = useState(false); const [importConflictsOpen, setImportConflictsOpen] = useState(false); const [importConflicts, setImportConflicts] = useState>([]); const [pendingImportSelections, setPendingImportSelections] = useState([]); const [importReportOpen, setImportReportOpen] = useState(false); const [importReport, setImportReport] = useState(null); const [previewDialogSceneId, setPreviewDialogSceneId] = useState(null); const [presentationOpen, setPresentationOpen] = useState(false); const [licenseSnap, setLicenseSnap] = useState(null); const [checkUpdatesOpen, setCheckUpdatesOpen] = useState(false); const [appPackaged, setAppPackaged] = useState(false); 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 renameFromPickerRef = useRef(false); /** Синхронно на dragStart, чтобы файловый drop колонки не успел сработать. */ const draggingListSceneIdRef = useRef(null); const showSceneDropSkippedNotice = useCallback( (rejected: { name: string; reason: 'unsupported' | 'no_path' }[]) => { if (rejected.length === 0) return; const reasonText = (reason: 'unsupported' | 'no_path') => reason === 'no_path' ? t('scenes.dropSkippedNoPath') : t('scenes.dropSkippedUnsupported'); const lines = rejected.map((r) => `• ${r.name} — ${reasonText(r.reason)}`); setAppNotice({ title: t('scenes.dropSkippedTitle'), message: `${t('scenes.dropSkippedIntro')}\n${lines.join('\n')}`, }); }, [t], ); const onScenesColumnDrop = useCallback( (entries: { path: string; name: string }[]) => { if (!state.project || state.creatingScene || state.sceneBatchImport) return; const { accepted, rejected } = partitionSceneMediaDrops(entries); if (accepted.length === 0) { showSceneDropSkippedNotice(rejected); return; } void (async () => { try { await actions.createScenesFromMediaPaths( accepted.map((entry) => ({ filePath: entry.path, title: sceneTitleFromMediaPath(entry.path), })), ); showSceneDropSkippedNotice(rejected); } catch (e) { setAppNotice({ title: t('common.error'), message: e instanceof Error ? e.message : String(e), }); } })(); }, [actions, showSceneDropSkippedNotice, state.creatingScene, state.project, state.sceneBatchImport, t], ); const scenesColumnDrop = useFileDropZone({ disabled: !state.project || state.creatingScene || state.sceneBatchImport !== null, isBlocked: () => draggingListSceneIdRef.current !== null, onDropEntries: onScenesColumnDrop, }); const sceneCardById = useStableSceneCardById(state.project); const graphUi = useMemo( () => ({ badgeStart: t('graph.badgeStart'), badgeSideStory: t('graph.badgeSideStory'), 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'), sideStoryStartScene: t('graph.sideStoryStartScene'), unsetSideStoryStartScene: t('graph.unsetSideStoryStartScene'), runFromScene: t('graph.runFromScene'), delete: t('common.delete'), }), [t], ); const fileMenuBtnRef = useRef(null); const projectMenuBtnRef = useRef(null); const settingsMenuBtnRef = useRef(null); const aboutMenuBtnRef = 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 [aboutMenuPos, setAboutMenuPos] = useState<{ left: number; top: number } | null>(null); const [selectedGraphNodeId, setSelectedGraphNodeId] = useState(null); const [sceneListDrop, setSceneListDrop] = useState<{ targetId: SceneId; place: 'before' | 'after'; } | null>(null); const [draggingListSceneId, setDraggingListSceneId] = useState(null); const sceneListScrollRef = useRef(null); useEffect(() => { if (!draggingListSceneId) return; const onWheel = (e: WheelEvent) => { const el = sceneListScrollRef.current; if (!el) return; // Во время HTML5-drag браузер глотает обычный scroll — крутим вручную. e.preventDefault(); el.scrollTop += e.deltaY; }; window.addEventListener('wheel', onWheel, { passive: false, capture: true }); return () => window.removeEventListener('wheel', onWheel, { capture: true }); }, [draggingListSceneId]); useEffect(() => { setSelectedGraphNodeId(null); }, [state.project?.id]); useEffect(() => { if (!state.project || !selectedGraphNodeId) return; if (!state.project.sceneGraphNodes.some((n) => n.id === selectedGraphNodeId)) { setSelectedGraphNodeId(null); } }, [selectedGraphNodeId, state.project?.sceneGraphNodes]); const scenes = useMemo(() => { const p = state.project; if (!p) return []; const order = reconcileSceneListOrder(p.scenes, p.sceneListOrder); return order .map((id) => p.scenes[id]) .filter((s): s is NonNullable => Boolean(s)) .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, })); }, [state.project, state.selectedSceneId]); const sceneListReorderEnabled = query.trim().length === 0; 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 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 runDisabled = !licenseActive || !graphStartGraphNodeId; const runHelpSection: HelpSectionId = !licenseActive ? 'license' : 'graph'; const runHelpTooltip = !licenseActive ? t('top.afterLicense') : t('top.setStartScene'); const launchFromGraphNode = useCallback( (graphNodeId: GraphNodeId) => { if (!licenseActive) return; void (async () => { await getDndApi().invoke(ipcChannels.project.setCurrentGraphNode, { graphNodeId }); await getDndApi().invoke(ipcChannels.windows.openMultiWindow, {}); })(); }, [licenseActive], ); 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(() => { if (!aboutMenuOpen) return; const r = aboutMenuBtnRef.current?.getBoundingClientRect() ?? null; queueMicrotask(() => { if (r) { setAboutMenuPos({ left: r.left, top: r.bottom + 10 }); } else { setAboutMenuPos(null); } }); const onDown = (e: MouseEvent) => { const t = e.target as HTMLElement | null; if (!t) return; if (t.closest('[data-aboutmenu-root="1"]')) return; setAboutMenuOpen(false); }; window.addEventListener('mousedown', onDown); return () => window.removeEventListener('mousedown', onDown); }, [aboutMenuOpen]); 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); setAppPackaged(r.packaged); } catch { setAppVersionText(null); } })(); }, []); const exportModalInitialProjectId = state.project?.id ?? state.projects[0]?.id ?? null; const storylineLabels = useStorylineLabels(); const targetHasMainStart = state.project?.sceneGraphNodes.some((n) => n.isStartScene) ?? false; const loadProjectStorylines = useCallback( (projectId: ProjectId) => actions.getProjectStorylines(projectId, storylineLabels), [actions, storylineLabels], ); const runStorylineMerge = useCallback( async (selections: StorylineSelection[], resolutions: SceneImportResolution[]) => { if (!importPeek) return; const { report } = importPeek.kind === 'project' && importPeek.sourceProjectId ? await actions.mergeImportFromProject(importPeek.sourceProjectId, selections, resolutions) : await actions.mergeImportZip(importPeek.filePath!, selections, resolutions); setImportReport(report); setImportReportOpen(true); setImportPeek(null); setImportStorylinesOpen(false); setImportConflictsOpen(false); setPendingImportSelections([]); setImportConflicts([]); }, [actions, importPeek], ); const handleImportSourceContinue = useCallback( async (selection: ImportSourceSelection) => { if (!state.project) { if (selection.kind === 'file') { await actions.importProjectFromPath(selection.filePath); setImportSourceOpen(false); } return; } const peek = selection.kind === 'project' ? await actions.peekImportFromProject( selection.sourceProjectId, storylineLabels, targetHasMainStart, ) : await actions.peekImportZipPath(selection.filePath, storylineLabels, targetHasMainStart); setImportPeek({ kind: selection.kind, ...(selection.kind === 'file' ? { filePath: selection.filePath } : { sourceProjectId: selection.sourceProjectId }), projectName: peek.projectName, storylines: peek.storylines, sourceProject: peek.sourceProject, }); setImportSourceOpen(false); setImportStorylinesOpen(true); }, [actions, state.project, storylineLabels, targetHasMainStart], ); const handleImportProject = useCallback(() => { setProjectMenuOpen(false); setImportSourceOpen(true); }, []); const goHome = useCallback(() => { setProjectMenuOpen(false); setExportModalOpen(false); setImportSourceOpen(false); setImportStorylinesOpen(false); setImportConflictsOpen(false); setImportReportOpen(false); setImportPeek(null); void actions.closeProject(); }, [actions]); 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.sceneBatchImport ? createPortal(
{t('scenes.batchTitle')}
{t('scenes.batchProgress') .replace('{current}', String(state.sceneBatchImport.current)) .replace('{total}', String(state.sceneBatchImport.total))} {state.sceneBatchImport.fileName ? `: ${state.sceneBatchImport.fileName}` : ''}
{Math.round( (state.sceneBatchImport.current / Math.max(1, state.sceneBatchImport.total)) * 100, )} %
, document.body, ) : null}
{state.project ? ( ) : null}
{appVersionText ? (
{appVersionText}
) : null}
{state.project ? ( <> {runDisabled ? ( ) : null} ) : null}
} left={
{state.project && scenesColumnDrop.dragOver ? (
{t('scenes.dropHint')}
) : null} {state.project ? ( <>
{filtered.map((s) => ( { setSelectedGraphNodeId(null); void actions.selectScene(s.id); }} onDeleteScene={(id) => void actions.deleteScene(id)} onDragListStart={(id) => { draggingListSceneIdRef.current = id; setDraggingListSceneId(id); }} onDragListEnd={() => { draggingListSceneIdRef.current = null; setDraggingListSceneId(null); setSceneListDrop(null); }} onDragListOver={(targetId, place) => { if (!sceneListReorderEnabled || !draggingListSceneIdRef.current) return; if (draggingListSceneIdRef.current === targetId) { setSceneListDrop(null); return; } setSceneListDrop((cur) => cur?.targetId === targetId && cur.place === place ? cur : { targetId, place }, ); }} onDropListReorder={(draggedId, targetId, place) => { if (!sceneListReorderEnabled || !state.project) { draggingListSceneIdRef.current = null; setSceneListDrop(null); setDraggingListSceneId(null); return; } const order = reconcileSceneListOrder( state.project.scenes, state.project.sceneListOrder, ); const next = moveSceneInListOrder(order, draggedId, targetId, place); draggingListSceneIdRef.current = null; setSceneListDrop(null); setDraggingListSceneId(null); void actions.setSceneListOrder(next); }} /> ))}
) : ( { renameFromPickerRef.current = true; void (async () => { try { await actions.openProject(id); setRenameOpen(true); } catch (e) { renameFromPickerRef.current = false; setAppNotice({ title: t('common.error'), message: e instanceof Error ? e.message : String(e), }); } })(); }} onDelete={actions.deleteProject} /> )}
} center={
{state.project ? ( { setSelectedGraphNodeId(graphNodeId); void actions.selectScene(sceneId); }} 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)} onSetGraphNodeSideStoryStart={(graphNodeId) => void actions.setSceneGraphNodeSideStoryStart(graphNodeId) } onRunFromGraphNode={launchFromGraphNode} 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), }); } })(); }} 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')}
{state.selectedSceneId ? ( (() => { const proj = state.project; const sid = state.selectedSceneId; const sc = proj.scenes[sid]; const previewImport = state.scenePreviewImports[sid] ?? null; const previewBusy = previewDialogSceneId === sid || previewImport !== null; const previewBusyText = previewDialogSceneId === sid ? t('scene.previewBusySelecting') : previewImport?.phase === 'done' ? t('scene.previewReady') : previewImport?.phase === 'error' ? t('scene.previewFailed') : previewImport !== null ? t('scene.previewOptimizing') : t('scene.previewBusy'); const sideStoryStartNodes = proj.sceneGraphNodes.filter( (n) => n.sceneId === sid && n.isSideStoryStart, ); return ( void actions.updateScene(sid, { media: { audios: next } }) } onPreviewVideoAutostartChange={(next) => void actions.updateScene(sid, { previewVideoAutostart: next }) } onDarkenSceneChange={(next) => void actions.updateScene(sid, { darkenScene: next })} onTitleChange={(title) => void actions.updateScene(sid, { title })} onDescriptionChange={(description) => void actions.updateScene(sid, { description }) } onImportPreview={() => { if (previewBusy) return; setPreviewDialogSceneId(sid); void (async () => { try { await actions.importScenePreview(sid); } catch (e) { setAppNotice({ title: t('common.error'), message: e instanceof Error ? e.message : String(e), }); } finally { setPreviewDialogSceneId((cur) => (cur === sid ? null : cur)); } })(); }} onClearPreview={() => void actions.clearScenePreview(sid)} onRotatePreview={(previewRotationDeg) => 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) } /> ); })() ) : (
{t('scenes.selectHint')}
)} ) : (
{t('scenes.openProjectHint')}
)}
} /> {settingsMenuOpen && settingsMenuPos ? createPortal(
{licenseActive && appPackaged ? ( ) : null}
{settingsLangSubOpen ? (
) : null}
, document.body, ) : null} setLicenseKeyModalOpen(false)} onSaved={() => { reloadLicense(); }} /> { setEulaModalOpen(false); setOpenKeyAfterEula(false); }} onAccepted={() => { if (openKeyAfterEula) { setLicenseKeyModalOpen(true); } setOpenKeyAfterEula(false); }} /> setAboutLicenseOpen(false)} snapshot={licenseSnap} /> setAppAboutOpen(false)} appVersion={appVersionText} /> setInstructionsOpen(false)} /> {aboutMenuOpen && aboutMenuPos ? createPortal(
, document.body, ) : null} {projectMenuOpen && projectMenuPos ? createPortal(
, document.body, ) : null} {fileMenuOpen && fileMenuPos && state.project ? createPortal(
, document.body, ) : null} {state.project ? ( { setRenameOpen(false); if (renameFromPickerRef.current) { renameFromPickerRef.current = false; void actions.closeProject(); } }} onSave={async (name, fileBaseName) => { await actions.renameProject(name, fileBaseName); }} /> ) : null} setExportModalOpen(false)} onExport={async (projectId, selections) => { await actions.exportProject(projectId, selections, storylineLabels); }} /> setImportSourceOpen(false)} onContinue={handleImportSourceContinue} /> { setImportStorylinesOpen(false); setImportPeek(null); }} onContinue={(selections) => { if (!importPeek || !state.project) return; const conflicts = computeImportConflicts(state.project, importPeek.sourceProject, selections); setPendingImportSelections(selections); if (conflicts.length > 0) { setImportConflicts(conflicts); setImportConflictsOpen(true); setImportStorylinesOpen(false); return; } const resolutions = buildSceneResolutionsForImport( state.project, importPeek.sourceProject, selections, [], [], ); void runStorylineMerge(selections, resolutions); }} /> { setImportConflictsOpen(false); setImportPeek(null); setPendingImportSelections([]); setImportConflicts([]); }} onConfirm={(userResolutions) => { if (!importPeek || !state.project) return; const resolutions = buildSceneResolutionsForImport( state.project, importPeek.sourceProject, pendingImportSelections, importConflicts, userResolutions, ); void runStorylineMerge(pendingImportSelections, resolutions); }} /> { setImportReportOpen(false); setImportReport(null); }} /> setCheckUpdatesOpen(false)} /> setAppNotice(null)} /> ); } type CheckUpdatesModalProps = { open: boolean; onClose: () => void; }; function formatUpdaterStageLabel( t: (key: string, vars?: Record) => string, ev: UpdaterProgressEvent | null, ): string { if (!ev) return t('updates.stage.checking'); const percentSuffix = ev.phase === 'downloading' && ev.percent !== undefined ? t('updates.stagePercent', { percent: ev.percent }) : ''; switch (ev.phase) { case 'checking': return t('updates.stage.checking'); case 'available': return t('updates.stage.available', { version: ev.version ?? '?' }); case 'not-available': return t('updates.stage.not-available'); case 'downloading': return t('updates.stage.downloading', { percent: percentSuffix }); case 'installing': return t('updates.stage.installing'); case 'error': return ev.message ? `${t('updates.stage.error')}: ${ev.message}` : t('updates.stage.error'); default: return t('updates.stage.checking'); } } function CheckUpdatesModal({ open, onClose }: CheckUpdatesModalProps) { const { t } = useEditorI18n(); const [phase, setPhase] = useState<'idle' | 'checking' | 'done'>('idle'); const [res, setRes] = useState(null); const [downloadBusy, setDownloadBusy] = useState(false); const [progress, setProgress] = useState(null); useEffect(() => { if (!open) return; startTransition(() => { setPhase('checking'); setRes(null); setProgress({ phase: 'checking' }); setDownloadBusy(false); }); void getDndApi() .invoke(ipcChannels.updater.check, {}) .then((r) => { setRes(r); setPhase('done'); if (r.outcome === 'available') { setProgress({ phase: 'available', version: r.version }); } else if (r.outcome === 'current') { setProgress({ phase: 'not-available', version: r.currentVersion }); } else if (r.outcome === 'error') { setProgress({ phase: 'error', message: r.message }); } }) .catch((e: unknown) => { const message = e instanceof Error ? e.message : String(e); setRes({ outcome: 'error', message }); setPhase('done'); setProgress({ phase: 'error', message }); }); }, [open]); useEffect(() => { if (!open) return; return getDndApi().on(ipcChannels.updater.progress, (ev) => { setProgress(ev); }); }, [open]); useEffect(() => { if (!open) return; const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape' && !downloadBusy) onClose(); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [downloadBusy, onClose, open]); if (!open) return null; const stageLine = t('updates.stageLine', { stage: formatUpdaterStageLabel(t, progress) }); const body = phase === 'checking' || res === null ? (
{t('updates.checking')}
) : res.outcome === 'available' ? (
{t('updates.available', { version: res.version })}
) : res.outcome === 'current' ? (
{t('updates.current', { version: res.currentVersion })}
) : res.outcome === 'error' ? (
{t('updates.error', { message: res.message })}
) : res.outcome === 'not_packaged' ? (
{t('updates.notPackaged')}
) : (
{t('updates.noLicense')}
); const showUpdateIdle = phase === 'done' && res !== null && res.outcome === 'available' && !downloadBusy; const showUpdateBusy = phase === 'done' && res !== null && res.outcome === 'available' && downloadBusy; return createPortal( <>
{body}
{stageLine}
{showUpdateIdle ? ( <> ) : showUpdateBusy ? ( ) : ( )}
, 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')}
{PROJECT_ZIP_EXTENSION}
{!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; openingProjectId: ProjectId | null; onCreate: (name: string) => Promise; onOpen: (id: ProjectId) => Promise; onRename: (id: ProjectId) => void; onDelete: (id: ProjectId) => Promise; }; function ProjectPicker({ projects, licenseActive, openingProjectId, onCreate, onOpen, onRename, onDelete, }: ProjectPickerProps) { const { t, locale } = useEditorI18n(); const [name, setName] = useState(() => t('picker.defaultName')); const [projectQuery, setProjectQuery] = useState(''); 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); const projectListScrollRef = useRef(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]); const trimmedName = name.trim(); const nameOk = trimmedName.length >= 3; const nameDup = projects.some((p) => normalizeName(p.name) === normalizeName(trimmedName)); const canCreate = licenseActive && nameOk && !nameDup; const filteredProjects = useMemo(() => { const q = projectQuery.trim().toLowerCase(); if (!q) return projects; return projects.filter((p) => p.name.toLowerCase().includes(q)); }, [projectQuery, projects]); return (
{t('picker.title')}
{!nameOk ?
{t('rename.projectMin')}
: null} {nameOk && nameDup ?
{t('rename.projectDup')}
: null}
{t('picker.existing')}
{!licenseActive && projects.length > 0 ? ( <>
{t('picker.lockedHint')}
) : null}
{filteredProjects.map((p) => { const isOpening = openingProjectId === p.id; const openDisabled = !licenseActive || openingProjectId !== null; return (
Boolean(className)) .join(' ')} onClick={() => { if (openDisabled) return; void onOpen(p.id); }} onDoubleClick={(e) => { e.preventDefault(); e.stopPropagation(); }} role="button" tabIndex={openDisabled ? -1 : 0} aria-busy={isOpening} title={ !licenseActive ? t('picker.openDisabled') : isOpening ? t('picker.opening') : undefined } onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { if (openDisabled) return; void onOpen(p.id); } }} >
{p.name}
{isOpening ? t('picker.opening') : new Date(p.updatedAt).toLocaleString(locale === 'en' ? 'en-US' : 'ru-RU')}
); })} {projects.length === 0 ?
{t('picker.empty')}
: null} {projects.length > 0 && filteredProjects.length === 0 ? (
{t('picker.searchEmpty')}
) : 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; sideStoryStartNodes: { id: GraphNodeId; sideStoryLineTitle: string }[]; previewAssetId: AssetId | null; previewAssetType: 'image' | 'video' | null; previewVideoAutostart: boolean; previewRotationDeg: 0 | 90 | 180 | 270; darkenScene: boolean; previewBusy: boolean; previewBusyText: string; mediaAssets: MediaAsset[]; audioRefs: SceneAudioRef[]; onAudioRefsChange: (next: SceneAudioRef[]) => void; onPreviewVideoAutostartChange: (next: boolean) => void; onDarkenSceneChange: (next: boolean) => void; onTitleChange: (v: string) => void; onDescriptionChange: (v: string) => void; onImportPreview: () => void; 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; }; type CampaignInspectorProps = { mediaAssets: MediaAsset[]; audioRefs: SceneAudioRef[]; onAudioRefsChange: (next: SceneAudioRef[]) => void; onUploadAudio: () => void; onDropAudio: (filePaths: string[]) => void; }; function CampaignInspector({ mediaAssets, 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')}
) : (
{mediaAssets .filter((a) => a.type === 'audio') .map((a) => (
{a.originalName}
))}
)}
); } function SceneInspector({ title, description, sideStoryStartNodes, previewAssetId, previewAssetType, previewVideoAutostart, previewRotationDeg, darkenScene, previewBusy, previewBusyText, mediaAssets, audioRefs, onAudioRefsChange, onPreviewVideoAutostartChange, onDarkenSceneChange, onTitleChange, onDescriptionChange, onImportPreview, onClearPreview, onRotatePreview, onUploadMedia, onDropAudio, onDropPreview, onSideStoryLineTitleChange, }: SceneInspectorProps) { const { t } = useEditorI18n(); const [descriptionModalOpen, setDescriptionModalOpen] = useState(false); const previewUrl = useAssetUrl(previewAssetId); const audioById = useMemo(() => new Map(audioRefs.map((a) => [a.assetId, a])), [audioRefs]); const descriptionEmpty = isSceneDescriptionEmpty(description); const descriptionPreviewHtml = useMemo( () => (descriptionEmpty ? '' : sanitizeSceneDescriptionHtml(description)), [description, descriptionEmpty], ); 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')}
{t('scene.description')}
{descriptionEmpty ? (
{t('scene.descriptionEmpty')}
) : (
)} {descriptionModalOpen ? ( setDescriptionModalOpen(false)} onSave={(html) => { onDescriptionChange(html); setDescriptionModalOpen(false); }} /> ) : null} {sideStoryStartNodes.length > 0 ? ( <>
{sideStoryStartNodes.map((gn) => (
{t('scene.sideStoryLineTitle')}
onSideStoryLineTitleChange(gn.id, v)} />
))} ) : null}
{t('scene.preview')}
{t('scene.previewHint')}
{previewDrop.dragOver ? (
{t('drop.hintPreview')}
) : null} {previewUrl && previewAssetType === 'image' ? (
) : previewUrl && previewAssetType === 'video' ? (
) : (
{t('scene.previewEmpty')}
)} {previewBusy ? (
{previewBusyText}
) : null}
{previewAssetId ? : null} {previewAssetId && previewAssetType === 'video' ? ( ) : null} {previewAssetId && previewAssetType === 'image' ? ( ) : null}
{previewAssetId && previewAssetType === 'image' ? ( <>
) : null}
{t('scene.audio')}
{sceneAudioDrop.dragOver ? (
{t('drop.hintAudio')}
) : null} {mediaAssets.filter((a) => a.type === 'audio').length === 0 ? (
{t('campaign.noFiles')}
) : (
{mediaAssets .filter((a) => a.type === 'audio') .map((a) => (
{a.originalName}
))}
)}
{t('scene.branching')}
{t('scene.branchingHint')}
); } type SceneListCardProps = { scene: SceneCard; reorderEnabled: boolean; listDragActive: boolean; dropPlace: 'before' | 'after' | null; isDragging: boolean; onSelect: () => void; onDeleteScene: (sceneId: SceneId) => void; onDragListStart: (sceneId: SceneId) => void; onDragListEnd: () => void; onDragListOver: (targetId: SceneId, place: 'before' | 'after') => void; onDropListReorder: (draggedId: SceneId, targetId: SceneId, place: 'before' | 'after') => void; }; function SceneListCard({ scene, reorderEnabled, listDragActive, dropPlace, isDragging, onSelect, onDeleteScene, onDragListStart, onDragListEnd, onDragListOver, onDropListReorder, }: SceneListCardProps) { const { t } = useEditorI18n(); const thumbUrl = useAssetUrl(scene.previewThumbAssetId); const previewUrl = useAssetUrl(scene.previewAssetId); const [menu, setMenu] = useState<{ x: number; y: number } | null>(null); useEffect(() => { if (!menu) return; const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setMenu(null); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [menu]); const menuPos = useMemo(() => { if (!menu) return null; const pad = 8; const mw = 180; const mh = 48; return { x: Math.max(pad, Math.min(menu.x, window.innerWidth - mw - pad)), y: Math.max(pad, Math.min(menu.y, window.innerHeight - mh - pad)), }; }, [menu]); const cardClass = [ styles.sceneCard, scene.active ? styles.sceneCardActive : '', isDragging ? styles.sceneCardDragging : '', dropPlace === 'before' ? styles.sceneCardDropBefore : '', dropPlace === 'after' ? styles.sceneCardDropAfter : '', ] .filter(Boolean) .join(' '); return (
{ // Сразу блокируем файловый drop колонки (до React re-render). onDragListStart(scene.id); e.dataTransfer.setData(DND_SCENE_ID_MIME, scene.id); e.dataTransfer.effectAllowed = reorderEnabled ? 'copyMove' : 'copy'; }} onDragEnd={() => onDragListEnd()} onDragOver={(e) => { if (!reorderEnabled || !listDragActive) return; e.preventDefault(); e.stopPropagation(); e.dataTransfer.dropEffect = 'move'; const rect = e.currentTarget.getBoundingClientRect(); const place: 'before' | 'after' = e.clientY < rect.top + rect.height / 2 ? 'before' : 'after'; onDragListOver(scene.id, place); }} onDrop={(e) => { if (!reorderEnabled || !listDragActive) return; const draggedId = e.dataTransfer.getData(DND_SCENE_ID_MIME); if (!draggedId) return; e.preventDefault(); e.stopPropagation(); const rect = e.currentTarget.getBoundingClientRect(); const place: 'before' | 'after' = e.clientY < rect.top + rect.height / 2 ? 'before' : 'after'; onDropListReorder(draggedId as SceneId, scene.id, place); }} onClick={onSelect} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onSelect(); }} >
{thumbUrl ? (
) : previewUrl && scene.previewAssetType === 'image' ? (
) : previewUrl && scene.previewAssetType === 'video' ? (
) : (
)}
{scene.active ?
{t('sceneCard.current')}
: null}
{scene.title}
{menu && menuPos ? createPortal( <>
, document.body, ) : null}
); }