Files
DndGamePlayer/app/renderer/editor/EditorApp.tsx
T
Ivan Fontosh 101f595bac feat(players): app Players library and circular NPC tokens on scenes
Add userData players/teams, scene npcTokens with hex-inscribed sizing, session scale synced to presentation, and Playwright e2e coverage.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 13:38:35 +08:00

2967 lines
110 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { moveSceneInListOrder, reconcileSceneListOrder } from '../../shared/graph/sceneListOrder';
import type {
NpcImportResolution,
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,
MaterialId,
MediaAsset,
Project,
ProjectId,
ProjectMaterial,
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 { FoundryImportModal } from './FoundryImportModal';
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 { MaterialEditModal, MaterialsManagerModal } from './MaterialsModals';
import { PlayersManagerModal } from './PlayersModals';
import { isSceneDescriptionEmpty, sanitizeSceneDescriptionHtml } from './sceneDescriptionHtml';
import { SceneDescriptionModal } from './SceneDescriptionModal';
import type { ProjectNoticeCode } from './state/projectState';
import { useProjectState } from './state/projectState';
import {
buildNpcResolutionsForImport,
buildSceneResolutionsForImport,
computeImportConflicts,
computeNpcImportConflicts,
ExportProjectModal,
ImportReportModal,
ImportSourceModal,
ImportStorylinesModal,
NpcConflictModal,
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<SceneId, SceneGraphSceneCard> {
const recordRef = useRef<Record<SceneId, SceneGraphSceneCard>>({});
const projectIdRef = useRef<ProjectId | null>(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<string | null>(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<HelpSectionId>('overview');
const [renameOpen, setRenameOpen] = useState(false);
const [exportModalOpen, setExportModalOpen] = useState(false);
const [importSourceOpen, setImportSourceOpen] = useState(false);
const [foundryImportOpen, setFoundryImportOpen] = useState(false);
const [importPeek, setImportPeek] = useState<ImportPeekResult | null>(null);
const [importStorylinesOpen, setImportStorylinesOpen] = useState(false);
const [importConflictsOpen, setImportConflictsOpen] = useState(false);
const [importConflicts, setImportConflicts] = useState<ReturnType<typeof computeImportConflicts>>([]);
const [importNpcConflictsOpen, setImportNpcConflictsOpen] = useState(false);
const [importNpcConflicts, setImportNpcConflicts] = useState<ReturnType<typeof computeNpcImportConflicts>>(
[],
);
const [pendingImportSelections, setPendingImportSelections] = useState<StorylineSelection[]>([]);
const [pendingSceneResolutions, setPendingSceneResolutions] = useState<SceneImportResolution[]>([]);
const [importReportOpen, setImportReportOpen] = useState(false);
const [importReport, setImportReport] = useState<StorylineImportMergeReport | null>(null);
const [previewDialogSceneId, setPreviewDialogSceneId] = useState<SceneId | null>(null);
const [presentationOpen, setPresentationOpen] = useState(false);
const [launching, setLaunching] = useState(false);
const [licenseSnap, setLicenseSnap] = useState<LicenseSnapshot | null>(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 [materialsManagerOpen, setMaterialsManagerOpen] = useState(false);
const [playersManagerOpen, setPlayersManagerOpen] = useState(false);
const [materialEdit, setMaterialEdit] = useState<ProjectMaterial | null | 'new'>(null);
const onProjectNotice = useCallback(
(code: ProjectNoticeCode) => {
const handlers: Record<ProjectNoticeCode, () => 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<SceneId | null>(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<SceneGraphUiStrings>(
() => ({
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<HTMLButtonElement | null>(null);
const projectMenuBtnRef = useRef<HTMLButtonElement | null>(null);
const settingsMenuBtnRef = useRef<HTMLButtonElement | null>(null);
const aboutMenuBtnRef = useRef<HTMLButtonElement | null>(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<GraphNodeId | null>(null);
const [sceneListDrop, setSceneListDrop] = useState<{
targetId: SceneId;
place: 'before' | 'after';
} | null>(null);
const [draggingListSceneId, setDraggingListSceneId] = useState<SceneId | null>(null);
const sceneListScrollRef = useRef<HTMLDivElement | null>(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, state.openingProjectId]);
useEffect(() => {
if (!state.project || !selectedGraphNodeId) return;
if (!state.project.sceneGraphNodes.some((n) => n.id === selectedGraphNodeId)) {
setSelectedGraphNodeId(null);
}
}, [selectedGraphNodeId, state.project?.sceneGraphNodes]);
const scenes = useMemo<SceneCard[]>(() => {
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<typeof s> => 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<MediaAsset[]>(() => {
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<SceneAudioRef[]>(() => {
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<SceneAudioRef[]>(() => {
return state.project?.campaignAudios ?? [];
}, [state.project]);
const campaignAudioAssets = useMemo<MediaAsset[]>(() => {
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 || launching) return;
setLaunching(true);
void (async () => {
try {
await getDndApi().invoke(ipcChannels.project.setCurrentGraphNode, { graphNodeId });
await getDndApi().invoke(ipcChannels.windows.openMultiWindow, {});
} catch {
setLaunching(false);
}
})();
},
[licenseActive, launching],
);
useEffect(() => {
if (presentationOpen) setLaunching(false);
}, [presentationOpen]);
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 clearImportFlow = useCallback(() => {
setImportPeek(null);
setImportStorylinesOpen(false);
setImportConflictsOpen(false);
setImportNpcConflictsOpen(false);
setPendingImportSelections([]);
setPendingSceneResolutions([]);
setImportConflicts([]);
setImportNpcConflicts([]);
}, []);
const runStorylineMerge = useCallback(
async (
selections: StorylineSelection[],
sceneResolutions: SceneImportResolution[],
npcResolutions: NpcImportResolution[],
) => {
if (!importPeek) return;
const { report } =
importPeek.kind === 'project' && importPeek.sourceProjectId
? await actions.mergeImportFromProject(
importPeek.sourceProjectId,
selections,
sceneResolutions,
npcResolutions,
)
: await actions.mergeImportZip(importPeek.filePath!, selections, sceneResolutions, npcResolutions);
setImportReport(report);
setImportReportOpen(true);
clearImportFlow();
},
[actions, clearImportFlow, importPeek],
);
const continueImportAfterScenes = useCallback(
(selections: StorylineSelection[], sceneResolutions: SceneImportResolution[]) => {
if (!importPeek || !state.project) return;
const npcConflicts = computeNpcImportConflicts(state.project, importPeek.sourceProject);
setPendingImportSelections(selections);
setPendingSceneResolutions(sceneResolutions);
setImportConflictsOpen(false);
if (npcConflicts.length > 0) {
setImportNpcConflicts(npcConflicts);
setImportNpcConflictsOpen(true);
return;
}
const npcResolutions = buildNpcResolutionsForImport(state.project, importPeek.sourceProject, [], []);
void runStorylineMerge(selections, sceneResolutions, npcResolutions);
},
[importPeek, runStorylineMerge, state.project],
);
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 handleImportFoundry = useCallback(() => {
setProjectMenuOpen(false);
setFoundryImportOpen(true);
}, []);
const goHome = useCallback(() => {
setProjectMenuOpen(false);
setExportModalOpen(false);
setImportSourceOpen(false);
setFoundryImportOpen(false);
setImportStorylinesOpen(false);
setImportConflictsOpen(false);
setImportReportOpen(false);
setImportPeek(null);
void actions.closeProject();
}, [actions]);
const bodyOverlay =
licenseSnap === null ? (
<div>
<div className={styles.licenseBlockTitle}>{t('license.checkingTitle')}</div>
<div className={styles.muted}>{t('license.checkingWait')}</div>
</div>
) : !licenseSnap.active ? (
<div>
<div className={styles.licenseBlockTitle}>{t('license.requiredTitle')}</div>
<div className={styles.muted}>{t('license.requiredHint')}</div>
</div>
) : undefined;
return (
<>
{launching
? createPortal(
<div className={styles.progressOverlay} role="dialog" aria-label={t('top.launching')} aria-busy>
<div className={styles.previewBusyModal}>
<div className={styles.previewSpinner} aria-hidden />
<div className={styles.previewBusyText}>{t('top.launching')}</div>
</div>
</div>,
document.body,
)
: null}
{presentationOpen
? createPortal(
<div className={styles.editorLockOverlay} role="dialog" aria-label={t('presentation.overlay')}>
<div className={styles.editorLockModal}>
<div className={styles.editorLockTitle}>{t('presentation.title')}</div>
<div className={styles.editorLockText}>{t('presentation.body')}</div>
</div>
</div>,
document.body,
)
: null}
{state.zipProgress
? createPortal(
<div className={styles.progressOverlay} role="dialog" aria-label={t('zip.progress')}>
<div className={styles.progressModal}>
<div className={styles.progressTitle}>
{state.zipProgress.kind === 'import' ? t('zip.importTitle') : t('zip.exportTitle')}
</div>
<div className={styles.progressBar}>
<div
className={styles.progressFill}
style={{ width: `${String(Math.max(0, Math.min(100, state.zipProgress.percent)))}%` }}
/>
</div>
<div className={styles.progressMeta}>
<div>{state.zipProgress.detail ?? state.zipProgress.stage}</div>
<div>{state.zipProgress.percent}%</div>
</div>
</div>
</div>,
document.body,
)
: null}
{state.sceneBatchImport
? createPortal(
<div className={styles.editorLockOverlay} role="dialog" aria-label={t('scenes.batchTitle')}>
<div className={styles.progressModal}>
<div className={styles.progressTitle}>{t('scenes.batchTitle')}</div>
<div className={styles.progressBar}>
<div
className={styles.progressFill}
style={{
width: `${String(
Math.max(
0,
Math.min(
100,
Math.round(
(state.sceneBatchImport.current / Math.max(1, state.sceneBatchImport.total)) *
100,
),
),
),
)}%`,
}}
/>
</div>
<div className={styles.progressMeta}>
<div>
{t('scenes.batchProgress')
.replace('{current}', String(state.sceneBatchImport.current))
.replace('{total}', String(state.sceneBatchImport.total))}
{state.sceneBatchImport.fileName ? `: ${state.sceneBatchImport.fileName}` : ''}
</div>
<div>
{Math.round(
(state.sceneBatchImport.current / Math.max(1, state.sceneBatchImport.total)) * 100,
)}
%
</div>
</div>
</div>
</div>,
document.body,
)
: null}
<LayoutShell
bodyOverlay={bodyOverlay}
topBar={
<div className={styles.topBarRow}>
<button
type="button"
className={styles.brandButton}
onClick={() => {
goHome();
}}
title={t('top.backToProjects')}
>
<AppLogo className={styles.brandLogo} size={26} />
<div className={styles.brandTitle}>{t('app.brandTitle')}</div>
</button>
<div className={styles.fileToolbar}>
<button
ref={settingsMenuBtnRef}
type="button"
data-settingsmenu-root="1"
className={styles.fileMenuTrigger}
onClick={() => {
setFileMenuOpen(false);
setProjectMenuOpen(false);
setAboutMenuOpen(false);
setSettingsMenuOpen((v) => {
const next = !v;
if (next) setSettingsLangSubOpen(false);
return next;
});
}}
>
{t('top.settings')}
</button>
<button
ref={aboutMenuBtnRef}
type="button"
data-aboutmenu-root="1"
className={styles.fileMenuTrigger}
onClick={() => {
setFileMenuOpen(false);
setProjectMenuOpen(false);
setSettingsMenuOpen(false);
setSettingsLangSubOpen(false);
setAboutMenuOpen((v) => !v);
}}
>
{t('top.aboutApp')}
</button>
<button
ref={projectMenuBtnRef}
type="button"
data-projectmenu-root="1"
className={styles.fileMenuTrigger}
disabled={!licenseActive}
title={!licenseActive ? t('top.afterLicense') : undefined}
onClick={() => {
if (!licenseActive) return;
setFileMenuOpen(false);
setSettingsMenuOpen(false);
setAboutMenuOpen(false);
setProjectMenuOpen((v) => !v);
}}
>
{t('top.project')}
</button>
{state.project ? (
<button
ref={fileMenuBtnRef}
type="button"
data-filemenu-root="1"
className={styles.fileMenuTrigger}
disabled={!licenseActive}
title={!licenseActive ? t('top.afterLicense') : undefined}
onClick={() => {
if (!licenseActive) return;
setProjectMenuOpen(false);
setSettingsMenuOpen(false);
setAboutMenuOpen(false);
setFileMenuOpen((v) => !v);
}}
>
{t('top.file')}
</button>
) : null}
<button
type="button"
data-testid="players-header-btn"
className={styles.fileMenuTrigger}
disabled={!licenseActive}
title={!licenseActive ? t('top.afterLicense') : undefined}
onClick={() => {
if (!licenseActive) return;
setProjectMenuOpen(false);
setSettingsMenuOpen(false);
setAboutMenuOpen(false);
setFileMenuOpen(false);
setPlayersManagerOpen(true);
}}
>
{t('top.players')}
</button>
</div>
<div className={styles.flex1} />
{appVersionText ? (
<div className={styles.appVersion} title={t('top.appVersion')}>
{appVersionText}
</div>
) : null}
<div className={styles.headerActions}>
{state.project ? (
<>
<Button
variant="primary"
disabled={runDisabled || launching}
onClick={() => {
if (!licenseActive || !graphStartGraphNodeId || launching) return;
launchFromGraphNode(graphStartGraphNodeId);
}}
>
{t('top.run')}
</Button>
{runDisabled ? (
<Button
variant="ghost"
iconOnly
ariaLabel={t('top.runHelpAria')}
title={runHelpTooltip}
tooltipPlacement="bottom-left"
onClick={() => {
setInstructionsSection(runHelpSection);
setInstructionsOpen(true);
}}
>
?
</Button>
) : null}
</>
) : null}
</div>
</div>
}
left={
<div
className={[
styles.editorSidebar,
state.project && scenesColumnDrop.dragOver ? styles.editorSidebarDragOver : '',
].join(' ')}
onDragEnter={state.project ? scenesColumnDrop.onDragEnter : undefined}
onDragLeave={state.project ? scenesColumnDrop.onDragLeave : undefined}
onDragOver={state.project ? scenesColumnDrop.onDragOver : undefined}
onDrop={state.project ? scenesColumnDrop.onDrop : undefined}
>
{state.project && scenesColumnDrop.dragOver ? (
<div className={styles.dropHintOverlay}>{t('scenes.dropHint')}</div>
) : null}
{state.project ? (
<>
<div className={styles.gridTools}>
<Input value={query} onChange={setQuery} placeholder={t('scenes.search')} />
<Button
variant="primary"
disabled={state.creatingScene || state.sceneBatchImport !== null}
onClick={() => void actions.createScene()}
>
{t('scenes.new')}
</Button>
</div>
<div className={styles.spacer14} />
<div className={styles.sidebarScroll} ref={sceneListScrollRef}>
<div className={styles.sceneListGrid}>
{filtered.map((s) => (
<SceneListCard
key={s.id}
scene={s}
reorderEnabled={sceneListReorderEnabled}
listDragActive={draggingListSceneId !== null}
dropPlace={sceneListDrop?.targetId === s.id ? sceneListDrop.place : null}
isDragging={draggingListSceneId === s.id}
onSelect={() => {
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);
}}
/>
))}
</div>
</div>
</>
) : (
<ProjectPicker
projects={state.projects}
licenseActive={licenseActive}
openingProjectId={state.openingProjectId}
onCreate={actions.createProject}
onOpen={actions.openProject}
onRename={(id) => {
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}
/>
)}
</div>
}
center={
<div className={styles.editorGraphHost}>
{state.project ? (
<SceneGraph
graphUi={graphUi}
sceneGraphNodes={state.project.sceneGraphNodes}
sceneGraphEdges={state.project.sceneGraphEdges}
sceneCardById={sceneCardById}
selectedGraphNodeId={selectedGraphNodeId}
onGraphNodeSelect={(graphNodeId, sceneId) => {
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)}
/>
) : (
<div className={styles.centerEmpty} />
)}
</div>
}
right={
<div className={styles.editorInspector}>
<div className={styles.inspectorScroll}>
{state.project ? (
<>
<div className={styles.inspectorTitle}>{t('scenes.inspectorGame')}</div>
<CampaignInspector
audioRefs={campaignAudioRefs}
mediaAssets={campaignAudioAssets}
onAudioRefsChange={(next) => 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),
});
}
})();
}}
/>
<div className={styles.spacer6} />
<div className={styles.gamePropsButtons}>
<Button onClick={() => setMaterialsManagerOpen(true)}>{t('materials.open')}</Button>
<Button
onClick={() => {
void getDndApi()
.invoke(ipcChannels.windows.openNpcsEditor, {})
.catch((err) => {
console.error('[editor] openNpcsEditor failed', err);
});
}}
>
{t('npcs.open')}
</Button>
</div>
<div className={styles.spacer18} />
<div className={styles.inspectorTitle}>{t('scenes.inspectorScene')}</div>
{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 (
<SceneInspector
title={sc?.title ?? ''}
description={sc?.description ?? ''}
sideStoryStartNodes={sideStoryStartNodes}
previewAssetId={sc?.previewAssetId ?? null}
previewAssetType={sc?.previewAssetType ?? null}
previewVideoAutostart={sc?.previewVideoAutostart ?? false}
previewRotationDeg={sc?.previewRotationDeg ?? 0}
darkenScene={sc?.darkenScene ?? false}
previewBusy={previewBusy}
previewBusyText={previewBusyText}
mediaAssets={sceneMediaAssets}
audioRefs={sceneAudioRefs}
onAudioRefsChange={(next) =>
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)
}
/>
);
})()
) : (
<div className={styles.muted}>{t('scenes.selectHint')}</div>
)}
</>
) : (
<div className={styles.muted}>{t('scenes.openProjectHint')}</div>
)}
</div>
</div>
}
/>
{settingsMenuOpen && settingsMenuPos
? createPortal(
<div
role="menu"
data-settingsmenu-root="1"
className={styles.fileMenu}
style={{ left: settingsMenuPos.left, top: settingsMenuPos.top }}
>
<button
type="button"
role="menuitem"
className={styles.fileMenuItem}
onClick={() => {
setSettingsMenuOpen(false);
setSettingsLangSubOpen(false);
if ((licenseSnap?.eulaAcceptedVersion ?? null) === EULA_CURRENT_VERSION) {
setLicenseKeyModalOpen(true);
} else {
setOpenKeyAfterEula(true);
setEulaModalOpen(true);
}
}}
>
{t('menu.enterKey')}
</button>
<button
type="button"
role="menuitem"
className={styles.fileMenuItem}
onClick={() => {
setSettingsMenuOpen(false);
setSettingsLangSubOpen(false);
setAboutLicenseOpen(true);
}}
>
{t('menu.aboutLicense')}
</button>
{licenseActive && appPackaged ? (
<button
type="button"
role="menuitem"
className={styles.fileMenuItem}
onClick={() => {
setSettingsMenuOpen(false);
setSettingsLangSubOpen(false);
setCheckUpdatesOpen(true);
}}
>
{t('menu.checkUpdates')}
</button>
) : null}
<div className={styles.fileMenuSubHost} role="presentation">
<button
type="button"
role="menuitem"
aria-haspopup="menu"
aria-expanded={settingsLangSubOpen}
className={[styles.fileMenuItem, styles.fileMenuItemExpand].join(' ')}
onClick={(e) => {
e.stopPropagation();
setSettingsLangSubOpen((o) => !o);
}}
>
<span>{t('menu.language')}</span>
<span aria-hidden></span>
</button>
{settingsLangSubOpen ? (
<div
role="menu"
aria-label={t('menu.language')}
className={styles.fileMenuSub}
data-settings-submenu-root="1"
>
<button
type="button"
role="menuitem"
className={styles.fileMenuItem}
onClick={() => {
setSettingsMenuOpen(false);
setSettingsLangSubOpen(false);
setLocale('ru');
}}
>
{locale === 'ru' ? '✓ ' : ''}
{t('menu.langRu')}
</button>
<button
type="button"
role="menuitem"
className={styles.fileMenuItem}
onClick={() => {
setSettingsMenuOpen(false);
setSettingsLangSubOpen(false);
setLocale('en');
}}
>
{locale === 'en' ? '✓ ' : ''}
{t('menu.langEn')}
</button>
</div>
) : null}
</div>
</div>,
document.body,
)
: null}
<LicenseTokenModal
open={licenseKeyModalOpen}
onClose={() => setLicenseKeyModalOpen(false)}
onSaved={() => {
reloadLicense();
}}
/>
<EulaModal
open={eulaModalOpen}
onClose={() => {
setEulaModalOpen(false);
setOpenKeyAfterEula(false);
}}
onAccepted={() => {
if (openKeyAfterEula) {
setLicenseKeyModalOpen(true);
}
setOpenKeyAfterEula(false);
}}
/>
<LicenseAboutModal
open={aboutLicenseOpen}
onClose={() => setAboutLicenseOpen(false)}
snapshot={licenseSnap}
/>
<AppAboutModal open={appAboutOpen} onClose={() => setAppAboutOpen(false)} appVersion={appVersionText} />
<InstructionsModal
open={instructionsOpen}
initialSection={instructionsSection}
onClose={() => setInstructionsOpen(false)}
/>
{aboutMenuOpen && aboutMenuPos
? createPortal(
<div
role="menu"
data-aboutmenu-root="1"
className={styles.fileMenu}
style={{ left: aboutMenuPos.left, top: aboutMenuPos.top }}
>
<button
type="button"
role="menuitem"
className={styles.fileMenuItem}
onClick={() => {
setAboutMenuOpen(false);
setAppAboutOpen(true);
}}
>
{t('menu.aboutProgram')}
</button>
<button
type="button"
role="menuitem"
className={styles.fileMenuItem}
onClick={() => {
setAboutMenuOpen(false);
setInstructionsSection('overview');
setInstructionsOpen(true);
}}
>
{t('menu.instructions')}
</button>
</div>,
document.body,
)
: null}
{projectMenuOpen && projectMenuPos
? createPortal(
<div
role="menu"
data-projectmenu-root="1"
className={styles.fileMenu}
style={{ left: projectMenuPos.left, top: projectMenuPos.top }}
>
<button
type="button"
role="menuitem"
className={styles.fileMenuItem}
onClick={() => {
goHome();
}}
>
{t('projectMenu.home')}
</button>
<button
type="button"
role="menuitem"
className={styles.fileMenuItem}
onClick={() => {
void handleImportProject();
}}
>
{t('projectMenu.import')}
</button>
{!state.project ? (
<button
type="button"
role="menuitem"
className={styles.fileMenuItem}
onClick={() => {
handleImportFoundry();
}}
>
{t('projectMenu.importFoundry')}
</button>
) : null}
<button
type="button"
role="menuitem"
className={styles.fileMenuItem}
disabled={state.projects.length === 0}
title={state.projects.length === 0 ? t('projectMenu.noProjects') : undefined}
onClick={() => {
setProjectMenuOpen(false);
setExportModalOpen(true);
}}
>
{t('projectMenu.export')}
</button>
</div>,
document.body,
)
: null}
{fileMenuOpen && fileMenuPos && state.project
? createPortal(
<div
role="menu"
data-filemenu-root="1"
className={styles.fileMenu}
style={{ left: fileMenuPos.left, top: fileMenuPos.top }}
>
<button
type="button"
role="menuitem"
className={styles.fileMenuItem}
onClick={() => {
setFileMenuOpen(false);
setRenameOpen(true);
}}
>
{t('fileMenu.rename')}
</button>
</div>,
document.body,
)
: null}
{state.project ? (
<RenameProjectModal
open={renameOpen}
projectNameInitial={currentProjectName}
fileBaseNameInitial={currentFileBaseName}
existingProjectNames={existingProjectNames}
existingFileBaseNames={existingFileBaseNames}
onClose={() => {
setRenameOpen(false);
if (renameFromPickerRef.current) {
renameFromPickerRef.current = false;
void actions.closeProject();
}
}}
onSave={async (name, fileBaseName) => {
await actions.renameProject(name, fileBaseName);
}}
/>
) : null}
<ExportProjectModal
open={exportModalOpen}
projects={state.projects}
initialProjectId={exportModalInitialProjectId}
storylineLabels={storylineLabels}
loadStorylines={loadProjectStorylines}
onClose={() => setExportModalOpen(false)}
onExport={async (projectId, selections, npcIds) => {
await actions.exportProject(projectId, selections, npcIds, storylineLabels);
}}
/>
<ImportSourceModal
open={importSourceOpen}
canImportFromProject={state.project !== null}
projects={state.projects}
currentProjectId={state.project?.id ?? null}
pickFile={actions.pickImportZipFile}
onClose={() => setImportSourceOpen(false)}
onContinue={handleImportSourceContinue}
/>
<FoundryImportModal
open={foundryImportOpen}
pickSource={actions.pickFoundrySource}
onClose={() => setFoundryImportOpen(false)}
onImport={async (selection) => {
// Сразу закрываем диалог, чтобы прогресс импорта не оказался под ним.
setFoundryImportOpen(false);
await actions.importFoundryProject(selection.sourcePath);
}}
/>
<ImportStorylinesModal
open={importStorylinesOpen}
sourceName={importPeek?.projectName ?? ''}
storylines={importPeek?.storylines ?? []}
onClose={() => {
setImportStorylinesOpen(false);
setImportPeek(null);
}}
onContinue={(selections) => {
if (!importPeek || !state.project) return;
const conflicts = computeImportConflicts(state.project, importPeek.sourceProject, selections);
setPendingImportSelections(selections);
setImportStorylinesOpen(false);
if (conflicts.length > 0) {
setImportConflicts(conflicts);
setImportConflictsOpen(true);
return;
}
const resolutions = buildSceneResolutionsForImport(
state.project,
importPeek.sourceProject,
selections,
[],
[],
);
continueImportAfterScenes(selections, resolutions);
}}
/>
<SceneConflictModal
open={importConflictsOpen}
conflicts={importConflicts}
onClose={clearImportFlow}
onConfirm={(userResolutions) => {
if (!importPeek || !state.project) return;
const resolutions = buildSceneResolutionsForImport(
state.project,
importPeek.sourceProject,
pendingImportSelections,
importConflicts,
userResolutions,
);
continueImportAfterScenes(pendingImportSelections, resolutions);
}}
/>
<NpcConflictModal
open={importNpcConflictsOpen}
conflicts={importNpcConflicts}
onClose={clearImportFlow}
onConfirm={(userNpcResolutions) => {
if (!importPeek || !state.project) return;
const npcResolutions = buildNpcResolutionsForImport(
state.project,
importPeek.sourceProject,
importNpcConflicts,
userNpcResolutions,
);
void runStorylineMerge(pendingImportSelections, pendingSceneResolutions, npcResolutions);
}}
/>
<ImportReportModal
open={importReportOpen}
report={importReport}
onClose={() => {
setImportReportOpen(false);
setImportReport(null);
}}
/>
<CheckUpdatesModal open={checkUpdatesOpen} onClose={() => setCheckUpdatesOpen(false)} />
<PlayersManagerModal open={playersManagerOpen} onClose={() => setPlayersManagerOpen(false)} />
<MaterialsManagerModal
open={materialsManagerOpen}
materials={state.project?.materials ?? []}
onClose={() => setMaterialsManagerOpen(false)}
onAdd={() => setMaterialEdit('new')}
onEdit={(m) => setMaterialEdit(m)}
onDelete={async (materialId: MaterialId) => {
try {
await actions.deleteMaterial(materialId);
} catch (e) {
setAppNotice({
title: t('common.error'),
message: e instanceof Error ? e.message : String(e),
});
}
}}
onReorder={async (materialIds) => {
try {
await actions.setMaterialsOrder(materialIds);
} catch (e) {
setAppNotice({
title: t('common.error'),
message: e instanceof Error ? e.message : String(e),
});
}
}}
onRotate={(materialId, rotationDeg) => {
void actions.setMaterialRotation(materialId, rotationDeg).catch((e) => {
setAppNotice({
title: t('common.error'),
message: e instanceof Error ? e.message : String(e),
});
});
}}
onLegendChange={async (materialId, legend) => {
try {
await actions.setMaterialLegend(materialId, legend);
} catch (e) {
setAppNotice({
title: t('common.error'),
message: e instanceof Error ? e.message : String(e),
});
}
}}
/>
<MaterialEditModal
open={materialEdit !== null}
initial={materialEdit && materialEdit !== 'new' ? materialEdit : null}
existingNames={(state.project?.materials ?? []).map((m) => m.name)}
onClose={() => setMaterialEdit(null)}
onPickImage={() => actions.pickMaterialImage()}
onSave={async ({ name, filePath }) => {
const materialId = materialEdit && materialEdit !== 'new' ? materialEdit.id : undefined;
await actions.upsertMaterial({
...(materialId ? { materialId } : {}),
name,
...(filePath ? { filePath } : {}),
});
}}
/>
<SimpleMessageModal
open={appNotice !== null}
title={appNotice?.title ?? t('common.message')}
message={appNotice?.message ?? ''}
onClose={() => setAppNotice(null)}
/>
</>
);
}
type CheckUpdatesModalProps = {
open: boolean;
onClose: () => void;
};
function formatUpdaterStageLabel(
t: (key: string, vars?: Record<string, string | number>) => 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<UpdaterCheckResponse | null>(null);
const [downloadBusy, setDownloadBusy] = useState(false);
const [progress, setProgress] = useState<UpdaterProgressEvent | null>(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 ? (
<div className={styles.muted}>{t('updates.checking')}</div>
) : res.outcome === 'available' ? (
<div className={styles.muted}>{t('updates.available', { version: res.version })}</div>
) : res.outcome === 'current' ? (
<div className={styles.muted}>{t('updates.current', { version: res.currentVersion })}</div>
) : res.outcome === 'error' ? (
<div className={styles.muted}>{t('updates.error', { message: res.message })}</div>
) : res.outcome === 'not_packaged' ? (
<div className={styles.muted}>{t('updates.notPackaged')}</div>
) : (
<div className={styles.muted}>{t('updates.noLicense')}</div>
);
const showUpdateIdle = phase === 'done' && res !== null && res.outcome === 'available' && !downloadBusy;
const showUpdateBusy = phase === 'done' && res !== null && res.outcome === 'available' && downloadBusy;
return createPortal(
<>
<button
type="button"
aria-label={t('common.close')}
onClick={() => {
if (!downloadBusy) onClose();
}}
className={styles.modalBackdrop}
/>
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('updates.dialogTitle')}</div>
<button
type="button"
aria-label={t('common.close')}
disabled={downloadBusy}
onClick={onClose}
className={styles.modalClose}
>
×
</button>
</div>
<div className={styles.fieldGrid}>
{body}
<div className={styles.muted}>{stageLine}</div>
</div>
<div className={styles.modalFooter}>
{showUpdateIdle ? (
<>
<Button variant="ghost" disabled={downloadBusy} onClick={onClose}>
{t('common.close')}
</Button>
<Button
variant="primary"
disabled={downloadBusy}
onClick={() => {
setDownloadBusy(true);
setProgress({ phase: 'downloading', version: res.version, percent: 0 });
void getDndApi()
.invoke(ipcChannels.updater.downloadAndRestart, { version: res.version })
.then((r) => {
if (!r.ok) {
setDownloadBusy(false);
setRes({ outcome: 'error', message: r.message });
setProgress({ phase: 'error', message: r.message });
}
})
.catch((e: unknown) => {
const message = e instanceof Error ? e.message : String(e);
setDownloadBusy(false);
setRes({ outcome: 'error', message });
setProgress({ phase: 'error', message });
});
}}
>
{t('updates.download')}
</Button>
</>
) : showUpdateBusy ? (
<Button variant="primary" disabled>
{progress?.phase === 'installing' ? t('updates.stage.installing') : t('updates.downloading')}
</Button>
) : (
<Button variant="primary" disabled={downloadBusy} onClick={onClose}>
{t('common.close')}
</Button>
)}
</div>
</div>
</>,
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(
<>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalBackdrop}
/>
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{title ?? t('common.message')}</div>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalClose}
>
×
</button>
</div>
<div className={styles.fieldGrid}>
<div className={[styles.muted, styles.noticeMessage].join(' ')}>{message}</div>
</div>
<div className={styles.modalFooter}>
<Button onClick={onClose}>{t('common.close')}</Button>
<Button variant="primary" onClick={onClose}>
{t('common.understood')}
</Button>
</div>
</div>
</>,
document.body,
);
}
type ConfirmDeleteProjectModalProps = {
open: boolean;
projectName: string;
busy: boolean;
onCancel: () => void;
onConfirm: () => void | Promise<void>;
};
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(
<>
<button
type="button"
aria-label={t('common.close')}
onClick={() => {
if (!busy) onCancel();
}}
className={styles.modalBackdrop}
/>
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('confirmDelete.title')}</div>
<button
type="button"
aria-label={t('common.close')}
disabled={busy}
onClick={onCancel}
className={styles.modalClose}
>
×
</button>
</div>
<div className={styles.fieldGrid}>
<div className={styles.muted}>{t('confirmDelete.body', { name: projectName })}</div>
</div>
<div className={styles.modalFooter}>
<Button onClick={onCancel} disabled={busy}>
{t('common.cancel')}
</Button>
<Button
variant="primary"
disabled={busy}
onClick={() => {
void onConfirm();
}}
>
{t('common.delete')}
</Button>
</div>
</div>
</>,
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<void>;
};
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<string | null>(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(
<>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalBackdrop}
/>
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('rename.title')}</div>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalClose}
>
×
</button>
</div>
<div className={styles.fieldGrid}>
<div className={styles.fieldLabel}>{t('rename.projectName')}</div>
<Input value={projectName} onChange={setProjectName} placeholder={t('rename.projectPlaceholder')} />
{!projectNameOk ? <div className={styles.fieldError}>{t('rename.projectMin')}</div> : null}
{projectNameDup ? <div className={styles.fieldError}>{t('rename.projectDup')}</div> : null}
</div>
<div className={styles.fieldGrid}>
<div className={styles.fieldLabel}>{t('rename.fileName')}</div>
<div className={styles.rowFlex}>
<div className={styles.flex1}>
<Input value={fileBaseName} onChange={setFileBaseName} placeholder="my_campaign" />
</div>
<div className={styles.fileSuffix}>{PROJECT_ZIP_EXTENSION}</div>
</div>
{!fileNameOk ? <div className={styles.fieldError}>{t('rename.fileInvalid')}</div> : null}
{fileNameDup ? <div className={styles.fieldError}>{t('rename.fileDup')}</div> : null}
</div>
{error ? <div className={styles.fieldError}>{error}</div> : null}
<div className={styles.modalFooter}>
<Button onClick={onClose} disabled={saving} title={saving ? t('rename.saving') : undefined}>
{t('common.cancel')}
</Button>
<Button
variant="primary"
disabled={!canSave}
onClick={() => {
if (!canSave) return;
void (async () => {
setSaving(true);
setError(null);
try {
await onSave(trimmedProjectName, trimmedFileBase);
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setSaving(false);
}
})();
}}
>
{t('common.save')}
</Button>
</div>
</div>
</>,
document.body,
);
}
type ProjectPickerProps = {
projects: { id: ProjectId; name: string; updatedAt: string }[];
licenseActive: boolean;
openingProjectId: ProjectId | null;
onCreate: (name: string) => Promise<void>;
onOpen: (id: ProjectId) => Promise<void>;
onRename: (id: ProjectId) => void;
onDelete: (id: ProjectId) => Promise<void>;
};
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<ProjectId | null>(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<string | null>(null);
const projectListScrollRef = useRef<HTMLDivElement | null>(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 (
<div className={styles.projectPicker}>
<div className={styles.projectPickerTitle}>{t('picker.title')}</div>
<div className={styles.projectPickerForm}>
<Input value={name} onChange={setName} placeholder={t('picker.newPlaceholder')} />
{!nameOk ? <div className={styles.fieldError}>{t('rename.projectMin')}</div> : null}
{nameOk && nameDup ? <div className={styles.fieldError}>{t('rename.projectDup')}</div> : null}
<Button
variant="primary"
disabled={!canCreate}
title={!licenseActive ? t('top.afterLicense') : undefined}
onClick={() => {
if (!canCreate) return;
void onCreate(trimmedName);
}}
>
{t('picker.create')}
</Button>
</div>
<div className={styles.spacer6} />
<div className={styles.gridTools}>
<Input value={projectQuery} onChange={setProjectQuery} placeholder={t('picker.search')} />
</div>
<div className={styles.spacer6} />
<div className={styles.sectionLabel}>{t('picker.existing')}</div>
{!licenseActive && projects.length > 0 ? (
<>
<div className={styles.muted}>{t('picker.lockedHint')}</div>
<div className={styles.spacer6} />
</>
) : null}
<div className={styles.projectListScroll} ref={projectListScrollRef}>
<div className={styles.projectList}>
{filteredProjects.map((p) => {
const isOpening = openingProjectId === p.id;
const openDisabled = !licenseActive || openingProjectId !== null;
return (
<div key={p.id} className={styles.projectCard}>
<div
className={[
styles.projectCardBody,
isOpening ? styles.projectCardBodyOpening : null,
openDisabled && !isOpening ? styles.projectCardBodyDisabled : null,
]
.filter((className): className is string => 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);
}
}}
>
<div className={styles.projectCardName}>{p.name}</div>
<div className={styles.projectCardMeta}>
{isOpening
? t('picker.opening')
: new Date(p.updatedAt).toLocaleString(locale === 'en' ? 'en-US' : 'ru-RU')}
</div>
</div>
<button
type="button"
className={styles.projectCardMenuBtn}
data-project-row-menu-root="1"
aria-label={t('picker.projectMenu')}
aria-haspopup="menu"
aria-expanded={rowMenuFor === p.id}
disabled={!licenseActive || openingProjectId !== null}
title={
!licenseActive
? t('top.afterLicense')
: openingProjectId !== null
? t('picker.opening')
: undefined
}
onClick={(e) => {
e.stopPropagation();
if (!licenseActive || openingProjectId !== null) return;
const r = e.currentTarget.getBoundingClientRect();
const menuW = 220;
const menuH = 96;
const gap = 8;
const left = Math.max(8, Math.min(r.right - menuW, window.innerWidth - menuW - 8));
const scrollEl = projectListScrollRef.current;
const scrollAtEnd =
!!scrollEl &&
scrollEl.scrollHeight > scrollEl.clientHeight &&
scrollEl.scrollTop + scrollEl.clientHeight >= scrollEl.scrollHeight - 2;
const openUp = scrollAtEnd || r.bottom + gap + menuH > window.innerHeight - 8;
const top = openUp ? Math.max(8, r.top - menuH - gap) : r.bottom + gap;
setRowMenuPos({ left, top });
setRowMenuFor((cur) => (cur === p.id ? null : p.id));
}}
>
</button>
</div>
);
})}
{projects.length === 0 ? <div className={styles.muted}>{t('picker.empty')}</div> : null}
{projects.length > 0 && filteredProjects.length === 0 ? (
<div className={styles.muted}>{t('picker.searchEmpty')}</div>
) : null}
</div>
</div>
{rowMenuFor && rowMenuPos
? createPortal(
<div
role="menu"
data-project-row-menu-root="1"
className={styles.fileMenu}
style={{ left: rowMenuPos.left, top: rowMenuPos.top }}
>
<button
type="button"
role="menuitem"
className={styles.fileMenuItem}
onClick={() => {
const id = rowMenuFor;
setRowMenuFor(null);
setRowMenuPos(null);
if (!id) return;
onRename(id);
}}
>
{t('fileMenu.rename')}
</button>
<button
type="button"
role="menuitem"
className={styles.fileMenuItemDanger}
onClick={() => {
const id = rowMenuFor;
const proj = projects.find((x) => x.id === id);
setRowMenuFor(null);
setRowMenuPos(null);
if (!id || !proj) return;
setPendingDelete({ id, name: proj.name });
}}
>
{t('common.delete')}
</button>
</div>,
document.body,
)
: null}
<ConfirmDeleteProjectModal
open={pendingDelete !== null}
projectName={pendingDelete?.name ?? ''}
busy={deleteSubmitting}
onCancel={() => {
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);
}
}}
/>
<SimpleMessageModal
open={deleteError !== null}
title={t('confirmDelete.failedTitle')}
message={deleteError ?? ''}
onClose={() => setDeleteError(null)}
/>
</div>
);
}
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 (
<div className={styles.sceneInspector}>
<div className={styles.labelSm}>{t('campaign.label')}</div>
<div
className={[styles.audioDrop, audioDrop.dragOver ? styles.audioDropDragOver : ''].join(' ')}
onDragEnter={audioDrop.onDragEnter}
onDragLeave={audioDrop.onDragLeave}
onDragOver={audioDrop.onDragOver}
onDrop={audioDrop.onDrop}
>
{audioDrop.dragOver ? <div className={styles.dropHintOverlay}>{t('drop.hintAudio')}</div> : null}
{mediaAssets.filter((a) => a.type === 'audio').length === 0 ? (
<div className={styles.audioDropEmpty}>
<div className={[styles.muted, styles.spanSm].join(' ')}>{t('campaign.noFiles')}</div>
<Button onClick={onUploadAudio}>{t('campaign.upload')}</Button>
</div>
) : (
<>
<div className={styles.audioList}>
{mediaAssets
.filter((a) => a.type === 'audio')
.map((a) => (
<div key={a.id} className={styles.audioRow}>
<span className={styles.audioName}>{a.originalName}</span>
<span className={styles.audioControls}>
<label className={styles.checkboxLabelSm}>
<input
type="checkbox"
checked={audioById.get(a.id)?.autoplay ?? false}
onChange={(e) => {
const next = audioRefs.map((x) =>
x.assetId === a.id ? { ...x, autoplay: e.target.checked } : x,
);
onAudioRefsChange(next);
}}
/>
<span className={styles.spanXs}>{t('campaign.auto')}</span>
</label>
<label className={styles.checkboxLabelSm}>
<input
type="checkbox"
checked={audioById.get(a.id)?.loop ?? false}
onChange={(e) => {
const next = audioRefs.map((x) =>
x.assetId === a.id ? { ...x, loop: e.target.checked } : x,
);
onAudioRefsChange(next);
}}
/>
<span className={styles.spanXs}>{t('campaign.loop')}</span>
</label>
<button
type="button"
title={t('campaign.removeTitle')}
className={styles.audioRemove}
onClick={() => {
onAudioRefsChange(audioRefs.filter((x) => x.assetId !== a.id));
}}
>
<svg
className={styles.audioRemoveIcon}
viewBox="0 0 24 24"
width={16}
height={16}
aria-hidden
>
<path
fill="currentColor"
d="M9 3h6a1 1 0 0 1 1 1v1h4v2H4V5h4V4a1 1 0 0 1 1-1zm1 5h2v9h-2V8zm4 0h2v9h-2V8zM7 8h2v9H7V8zm9-3H8v1h8V5zM6 21a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V8H6v13z"
/>
</svg>
</button>
</span>
</div>
))}
</div>
<Button onClick={onUploadAudio}>{t('campaign.upload')}</Button>
</>
)}
</div>
</div>
);
}
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 (
<div className={styles.sceneInspector}>
<div className={styles.labelSm}>{t('scene.title')}</div>
<Input value={title} onChange={onTitleChange} />
<div className={styles.spacer8} />
<div className={styles.labelRow}>
<div className={styles.labelSm}>{t('scene.description')}</div>
<Button
iconOnly
title={t('common.edit')}
ariaLabel={t('common.edit')}
onClick={() => setDescriptionModalOpen(true)}
>
<svg viewBox="0 0 24 24" width={14} height={14} aria-hidden>
<path
fill="currentColor"
d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04a1 1 0 0 0 0-1.41l-2.34-2.34a1 1 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"
/>
</svg>
</Button>
</div>
{descriptionEmpty ? (
<div className={styles.descriptionEmpty}>{t('scene.descriptionEmpty')}</div>
) : (
<div
className={styles.descriptionPreview}
dangerouslySetInnerHTML={{ __html: descriptionPreviewHtml }}
/>
)}
{descriptionModalOpen ? (
<SceneDescriptionModal
initialHtml={description}
onClose={() => setDescriptionModalOpen(false)}
onSave={(html) => {
onDescriptionChange(html);
setDescriptionModalOpen(false);
}}
/>
) : null}
{sideStoryStartNodes.length > 0 ? (
<>
<div className={styles.spacer8} />
{sideStoryStartNodes.map((gn) => (
<div key={gn.id}>
<div className={styles.labelSm}>{t('scene.sideStoryLineTitle')}</div>
<Input value={gn.sideStoryLineTitle} onChange={(v) => onSideStoryLineTitleChange(gn.id, v)} />
<div className={styles.spacer8} />
</div>
))}
</>
) : null}
<div className={styles.spacer6} />
<div className={styles.labelSm}>{t('scene.preview')}</div>
<div className={styles.hint}>{t('scene.previewHint')}</div>
<div
className={[styles.previewBox, previewDrop.dragOver ? styles.previewBoxDragOver : ''].join(' ')}
onDragEnter={previewDrop.onDragEnter}
onDragLeave={previewDrop.onDragLeave}
onDragOver={previewDrop.onDragOver}
onDrop={previewDrop.onDrop}
>
{previewDrop.dragOver ? <div className={styles.dropHintOverlay}>{t('drop.hintPreview')}</div> : null}
{previewUrl && previewAssetType === 'image' ? (
<div className={styles.previewFill}>
<RotatedImage
url={previewUrl}
rotationDeg={previewRotationDeg}
mode="cover"
style={{ width: '100%', height: '100%' }}
/>
</div>
) : previewUrl && previewAssetType === 'video' ? (
<div className={styles.previewFill}>
<video
src={previewUrl}
muted
playsInline
autoPlay={previewVideoAutostart}
loop
preload="metadata"
className={styles.videoCover}
/>
</div>
) : (
<div className={styles.previewEmpty}>{t('scene.previewEmpty')}</div>
)}
{previewBusy ? (
<div className={styles.previewBusyOverlay} aria-live="polite">
<div className={styles.previewBusyModal}>
<div className={styles.previewSpinner} aria-hidden />
<div className={styles.previewBusyText}>{previewBusyText}</div>
</div>
</div>
) : null}
</div>
<div className={styles.actionsRow}>
<Button variant="primary" disabled={previewBusy} onClick={onImportPreview}>
{previewAssetId ? t('scene.change') : t('campaign.upload')}
</Button>
{previewAssetId ? <Button onClick={onClearPreview}>{t('scene.clear')}</Button> : null}
{previewAssetId && previewAssetType === 'video' ? (
<label className={styles.checkboxLabel}>
<input
type="checkbox"
checked={previewVideoAutostart}
onChange={(e) => onPreviewVideoAutostartChange(e.target.checked)}
/>
<span className={styles.spanSm}>{t('scene.autostart')}</span>
</label>
) : null}
{previewAssetId && previewAssetType === 'image' ? (
<Button
onClick={() => {
const next = ((previewRotationDeg + 90) % 360) as 0 | 90 | 180 | 270;
onRotatePreview(next);
}}
>
{t('scene.rotate')}
</Button>
) : null}
</div>
{previewAssetId && previewAssetType === 'image' ? (
<>
<div className={styles.spacer6} />
<label className={styles.checkboxLabel}>
<input
type="checkbox"
checked={darkenScene}
onChange={(e) => onDarkenSceneChange(e.target.checked)}
/>
<span className={styles.spanSm}>{t('scene.darkenScene')}</span>
</label>
<div className={styles.spacer6} />
<Button
onClick={() => {
void getDndApi().invoke(ipcChannels.windows.openSceneEditor, {});
}}
>
Редактор сцены
</Button>
</>
) : null}
<div className={styles.spacer6} />
<div className={styles.labelSm}>{t('scene.audio')}</div>
<div
className={[styles.audioDrop, sceneAudioDrop.dragOver ? styles.audioDropDragOver : ''].join(' ')}
onDragEnter={sceneAudioDrop.onDragEnter}
onDragLeave={sceneAudioDrop.onDragLeave}
onDragOver={sceneAudioDrop.onDragOver}
onDrop={sceneAudioDrop.onDrop}
>
{sceneAudioDrop.dragOver ? <div className={styles.dropHintOverlay}>{t('drop.hintAudio')}</div> : null}
{mediaAssets.filter((a) => a.type === 'audio').length === 0 ? (
<div className={styles.audioDropEmpty}>
<div className={[styles.muted, styles.spanSm].join(' ')}>{t('campaign.noFiles')}</div>
<Button onClick={onUploadMedia}>{t('campaign.upload')}</Button>
</div>
) : (
<>
<div className={styles.audioList}>
{mediaAssets
.filter((a) => a.type === 'audio')
.map((a) => (
<div key={a.id} className={styles.audioRow}>
<span className={styles.audioName}>{a.originalName}</span>
<span className={styles.audioControls}>
<label className={styles.checkboxLabelSm}>
<input
type="checkbox"
checked={audioById.get(a.id)?.autoplay ?? false}
onChange={(e) => {
const next = audioRefs.map((x) =>
x.assetId === a.id ? { ...x, autoplay: e.target.checked } : x,
);
onAudioRefsChange(next);
}}
/>
<span className={styles.spanXs}>{t('campaign.auto')}</span>
</label>
<label className={styles.checkboxLabelSm}>
<input
type="checkbox"
checked={audioById.get(a.id)?.loop ?? false}
onChange={(e) => {
const next = audioRefs.map((x) =>
x.assetId === a.id ? { ...x, loop: e.target.checked } : x,
);
onAudioRefsChange(next);
}}
/>
<span className={styles.spanXs}>{t('campaign.loop')}</span>
</label>
<button
type="button"
title={t('scene.removeTitle')}
className={styles.audioRemove}
onClick={() => {
onAudioRefsChange(audioRefs.filter((x) => x.assetId !== a.id));
}}
>
<svg
className={styles.audioRemoveIcon}
viewBox="0 0 24 24"
width={16}
height={16}
aria-hidden
>
<path
fill="currentColor"
d="M9 3h6a1 1 0 0 1 1 1v1h4v2H4V5h4V4a1 1 0 0 1 1-1zm1 5h2v9h-2V8zm4 0h2v9h-2V8zM7 8h2v9H7V8zm9-3H8v1h8V5zM6 21a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V8H6v13z"
/>
</svg>
</button>
</span>
</div>
))}
</div>
<Button onClick={onUploadMedia}>{t('campaign.upload')}</Button>
</>
)}
</div>
<div className={styles.spacer6} />
<div className={styles.labelSm}>{t('scene.branching')}</div>
<div className={styles.hintBlock}>{t('scene.branchingHint')}</div>
</div>
);
}
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);
const [pendingDelete, setPendingDelete] = useState(false);
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 (
<div
draggable
className={cardClass}
onDragStart={(e) => {
// Сразу блокируем файловый 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();
}}
>
<div className={thumbUrl || previewUrl ? styles.sceneThumb : styles.sceneThumbEmpty}>
{thumbUrl ? (
<div className={styles.sceneThumbInner}>
<RotatedImage
url={thumbUrl}
rotationDeg={scene.previewRotationDeg}
mode="cover"
loading="lazy"
decoding="async"
/>
</div>
) : previewUrl && scene.previewAssetType === 'image' ? (
<div className={styles.sceneThumbInner}>
<RotatedImage
url={previewUrl}
rotationDeg={scene.previewRotationDeg}
mode="cover"
loading="lazy"
decoding="async"
/>
</div>
) : previewUrl && scene.previewAssetType === 'video' ? (
<div className={styles.sceneThumbInner}>
<video
src={previewUrl}
muted
playsInline
preload="metadata"
draggable={false}
className={styles.sceneThumbVideo}
onLoadedData={(e) => {
const v = e.currentTarget;
try {
v.currentTime = 0;
v.pause();
} catch {
// ignore
}
}}
/>
</div>
) : (
<div className={styles.sceneThumbEmptyInner} aria-hidden />
)}
</div>
<div className={styles.sceneCardBody}>
<div className={styles.sceneCardHeader}>
{scene.active ? <div className={styles.badgeCurrent}>{t('sceneCard.current')}</div> : null}
<button
type="button"
aria-label={t('sceneCard.menu')}
className={styles.sceneMenuBtn}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
setMenu({ x: e.clientX, y: e.clientY });
}}
>
</button>
</div>
<div className={styles.sceneCardTitle}>{scene.title}</div>
</div>
{menu && menuPos
? createPortal(
<>
<button
type="button"
aria-label={t('common.closeMenu')}
className={styles.menuBackdrop}
onClick={() => setMenu(null)}
/>
<div
role="menu"
tabIndex={-1}
className={styles.sceneCtxMenu}
style={{ left: menuPos.x, top: menuPos.y }}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === 'Escape') setMenu(null);
}}
>
<button
type="button"
role="menuitem"
className={styles.sceneCtxDanger}
onClick={() => {
setMenu(null);
setPendingDelete(true);
}}
>
{t('common.delete')}
</button>
</div>
</>,
document.body,
)
: null}
{pendingDelete
? createPortal(
<>
<button
type="button"
aria-label={t('common.close')}
className={styles.modalBackdrop}
onClick={() => setPendingDelete(false)}
/>
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('confirmDeleteScene.title')}</div>
<button
type="button"
aria-label={t('common.close')}
className={styles.modalClose}
onClick={() => setPendingDelete(false)}
>
×
</button>
</div>
<div className={styles.fieldGrid}>
<div className={styles.muted}>{t('confirmDeleteScene.body', { name: scene.title })}</div>
</div>
<div className={styles.modalFooter}>
<Button onClick={() => setPendingDelete(false)}>{t('common.cancel')}</Button>
<Button
variant="primary"
onClick={() => {
setPendingDelete(false);
onDeleteScene(scene.id);
}}
>
{t('common.delete')}
</Button>
</div>
</div>
</>,
document.body,
)
: null}
</div>
);
}