Files
DndGamePlayer/app/renderer/editor/EditorApp.tsx
T
Ivan Fontosh 2fa20da94d Лицензия, редактор, пульт и сборка
- Main: license service, IPC, router; закрытие окон; yauzl закрытие zip (EMFILE), zipRead тест
- Editor: стабильный projectState без мигания, логотип и меню, строки UI, LayoutShell overlay
- Control: ластик для всех типов эффектов, затухание/нарастание музыки при смене сцены
- Сборка: vite, build/dev scripts, obfuscate-main и build-env скрипты с тестами; package.json

Made-with: Cursor
2026-04-19 20:11:24 +08:00

1237 lines
45 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, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { ipcChannels } from '../../shared/ipc/contracts';
import { EULA_CURRENT_VERSION } from '../../shared/license/eulaVersion';
import type { LicenseSnapshot } from '../../shared/license/licenseSnapshot';
import type { AssetId, MediaAsset, ProjectId, SceneAudioRef, SceneId } from '../../shared/types';
import { AppLogo } from '../shared/branding/AppLogo';
import { getDndApi } from '../shared/dndApi';
import { RotatedImage } from '../shared/RotatedImage';
import { Button, Input } from '../shared/ui/controls';
import { LayoutShell } from '../shared/ui/LayoutShell';
import { useAssetUrl } from '../shared/useAssetImageUrl';
import styles from './EditorApp.module.css';
import { DND_SCENE_ID_MIME, SceneGraph } from './graph/SceneGraph';
import { EulaModal, LicenseAboutModal, LicenseTokenModal } from './license/EditorLicenseModals';
import { useProjectState } from './state/projectState';
type SceneCard = {
id: SceneId;
title: string;
active: boolean;
previewAssetId: AssetId | null;
previewAssetType: 'image' | 'video' | null;
previewVideoAutostart: boolean;
previewRotationDeg: 0 | 90 | 180 | 270;
};
export function EditorApp() {
const [appVersionText, setAppVersionText] = useState<string | null>(null);
const [query, setQuery] = useState('');
const [fileMenuOpen, setFileMenuOpen] = useState(false);
const [projectMenuOpen, setProjectMenuOpen] = useState(false);
const [settingsMenuOpen, setSettingsMenuOpen] = useState(false);
const [renameOpen, setRenameOpen] = useState(false);
const [exportModalOpen, setExportModalOpen] = useState(false);
const [licenseSnap, setLicenseSnap] = useState<LicenseSnapshot | null>(null);
const [licenseKeyModalOpen, setLicenseKeyModalOpen] = useState(false);
const [eulaModalOpen, setEulaModalOpen] = useState(false);
const [aboutLicenseOpen, setAboutLicenseOpen] = useState(false);
const [openKeyAfterEula, setOpenKeyAfterEula] = useState(false);
const licenseActive = licenseSnap?.active === true;
const [state, actions] = useProjectState(licenseActive);
const fileMenuBtnRef = useRef<HTMLButtonElement | null>(null);
const projectMenuBtnRef = useRef<HTMLButtonElement | null>(null);
const settingsMenuBtnRef = 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 scenes = useMemo<SceneCard[]>(() => {
const p = state.project;
if (!p) return [];
return Object.values(p.scenes).map((s) => ({
id: s.id,
title: s.title,
active: s.id === state.selectedSceneId,
previewAssetId: s.previewAssetId,
previewAssetType: s.previewAssetType,
previewVideoAutostart: s.previewVideoAutostart,
previewRotationDeg: s.previewRotationDeg,
}));
}, [state.project, state.selectedSceneId]);
const filtered = useMemo(
() => scenes.filter((s) => s.title.toLowerCase().includes(query.trim().toLowerCase())),
[query, scenes],
);
const sceneMediaAssets = useMemo<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 graphStartSceneId = useMemo(() => {
const p = state.project;
if (!p) return null;
const gn = p.sceneGraphNodes.find((n) => n.isStartScene);
return gn?.sceneId ?? null;
}, [state.project]);
const graphStartGraphNodeId = useMemo(() => {
const p = state.project;
if (!p) return null;
const gn = p.sceneGraphNodes.find((n) => n.isStartScene);
return gn?.id ?? null;
}, [state.project]);
const currentProjectName = state.project?.meta.name ?? '';
const currentFileBaseName = state.project?.meta.fileBaseName ?? '';
const existingProjectNames = useMemo(() => state.projects.map((p) => p.name), [state.projects]);
const existingFileBaseNames = useMemo(() => {
return state.projects.map((p) => p.fileName.replace(/\.dnd\.zip$/iu, ''));
}, [state.projects]);
useEffect(() => {
if (!fileMenuOpen) return;
const r = fileMenuBtnRef.current?.getBoundingClientRect() ?? null;
queueMicrotask(() => {
if (r) {
setFileMenuPos({ left: r.left, top: r.bottom + 10 });
} else {
setFileMenuPos(null);
}
});
const onDown = (e: MouseEvent) => {
const t = e.target as HTMLElement | null;
if (!t) return;
if (t.closest('[data-filemenu-root="1"]')) return;
setFileMenuOpen(false);
};
window.addEventListener('mousedown', onDown);
return () => window.removeEventListener('mousedown', onDown);
}, [fileMenuOpen]);
useEffect(() => {
if (!projectMenuOpen) return;
const r = projectMenuBtnRef.current?.getBoundingClientRect() ?? null;
queueMicrotask(() => {
if (r) {
setProjectMenuPos({ left: r.left, top: r.bottom + 10 });
} else {
setProjectMenuPos(null);
}
});
const onDown = (e: MouseEvent) => {
const t = e.target as HTMLElement | null;
if (!t) return;
if (t.closest('[data-projectmenu-root="1"]')) return;
setProjectMenuOpen(false);
};
window.addEventListener('mousedown', onDown);
return () => window.removeEventListener('mousedown', onDown);
}, [projectMenuOpen]);
useEffect(() => {
if (!settingsMenuOpen) return;
const r = settingsMenuBtnRef.current?.getBoundingClientRect() ?? null;
queueMicrotask(() => {
if (r) {
setSettingsMenuPos({ left: r.left, top: r.bottom + 10 });
} else {
setSettingsMenuPos(null);
}
});
const onDown = (e: MouseEvent) => {
const t = e.target as HTMLElement | null;
if (!t) return;
if (t.closest('[data-settingsmenu-root="1"]')) return;
setSettingsMenuOpen(false);
};
window.addEventListener('mousedown', onDown);
return () => window.removeEventListener('mousedown', onDown);
}, [settingsMenuOpen]);
const reloadLicense = useCallback(() => {
void (async () => {
try {
const s = await getDndApi().invoke(ipcChannels.license.getStatus, {});
setLicenseSnap(s);
} catch {
setLicenseSnap(null);
}
})();
}, []);
useEffect(() => {
reloadLicense();
const unsub = getDndApi().on(ipcChannels.license.statusChanged, () => {
reloadLicense();
});
return unsub;
}, [reloadLicense]);
useEffect(() => {
void (async () => {
try {
const r = await getDndApi().invoke(ipcChannels.app.getVersion, {});
const label = r.buildNumber ? `v${r.version} · ${r.buildNumber}` : `v${r.version}`;
setAppVersionText(label);
} catch {
setAppVersionText(null);
}
})();
}, []);
const exportModalInitialProjectId = state.project?.id ?? state.projects[0]?.id ?? null;
const bodyOverlay =
licenseSnap === null ? (
<div>
<div className={styles.licenseBlockTitle}>Проверка лицензии</div>
<div className={styles.muted}>Подождите.</div>
</div>
) : !licenseSnap.active ? (
<div>
<div className={styles.licenseBlockTitle}>Требуется лицензия</div>
<div className={styles.muted}>
Укажите ключ в меню «Настройки» «Указать ключ». До активации доступно только меню «Настройки».
</div>
</div>
) : undefined;
return (
<>
<LayoutShell
bodyOverlay={bodyOverlay}
topBar={
<div className={styles.topBarRow}>
<button
type="button"
className={styles.brandButton}
onClick={() => {
void actions.closeProject();
}}
title="К списку проектов"
>
<AppLogo className={styles.brandLogo} size={26} />
<div className={styles.brandTitle}>DNDGamePlayer</div>
</button>
<div className={styles.fileToolbar}>
<button
ref={settingsMenuBtnRef}
type="button"
data-settingsmenu-root="1"
className={styles.fileMenuTrigger}
onClick={() => {
setFileMenuOpen(false);
setProjectMenuOpen(false);
setSettingsMenuOpen((v) => !v);
}}
>
Настройки
</button>
<button
ref={projectMenuBtnRef}
type="button"
data-projectmenu-root="1"
className={styles.fileMenuTrigger}
disabled={!licenseActive}
title={!licenseActive ? 'Доступно после активации лицензии' : undefined}
onClick={() => {
if (!licenseActive) return;
setFileMenuOpen(false);
setSettingsMenuOpen(false);
setProjectMenuOpen((v) => !v);
}}
>
Проект
</button>
{state.project ? (
<button
ref={fileMenuBtnRef}
type="button"
data-filemenu-root="1"
className={styles.fileMenuTrigger}
disabled={!licenseActive}
title={!licenseActive ? 'Доступно после активации лицензии' : undefined}
onClick={() => {
if (!licenseActive) return;
setProjectMenuOpen(false);
setSettingsMenuOpen(false);
setFileMenuOpen((v) => !v);
}}
>
Файл
</button>
) : null}
</div>
<div className={styles.flex1} />
{appVersionText ? (
<div className={styles.appVersion} title="Версия приложения">
{appVersionText}
</div>
) : null}
<div className={styles.headerActions}>
{state.project ? (
<Button
variant="primary"
disabled={!licenseActive || !graphStartGraphNodeId}
title={
!licenseActive
? 'Доступно после активации лицензии'
: graphStartSceneId
? undefined
: 'Назначьте начальную сцену на графе (ПКМ по узлу)'
}
onClick={() => {
if (!licenseActive || !graphStartGraphNodeId) return;
void (async () => {
await getDndApi().invoke(ipcChannels.project.setCurrentGraphNode, {
graphNodeId: graphStartGraphNodeId,
});
await getDndApi().invoke(ipcChannels.windows.openMultiWindow, {});
})();
}}
>
Запустить
</Button>
) : null}
</div>
</div>
}
left={
<div className={styles.editorSidebar}>
{state.project ? (
<>
<div className={styles.gridTools}>
<Input value={query} onChange={setQuery} placeholder="Поиск сцен…" />
<Button variant="primary" onClick={() => void actions.createScene()}>
+ Новая сцена
</Button>
</div>
<div className={styles.spacer14} />
<div className={styles.sidebarScroll}>
<div className={styles.sceneListGrid}>
{filtered.map((s) => (
<SceneListCard
key={s.id}
scene={s}
onSelect={() => void actions.selectScene(s.id)}
onDeleteScene={(id) => void actions.deleteScene(id)}
/>
))}
</div>
</div>
</>
) : (
<ProjectPicker
projects={state.projects}
onCreate={actions.createProject}
onOpen={actions.openProject}
onDelete={actions.deleteProject}
/>
)}
</div>
}
center={
<div className={styles.editorGraphHost}>
{state.project ? (
<SceneGraph
sceneGraphNodes={state.project.sceneGraphNodes}
sceneGraphEdges={state.project.sceneGraphEdges}
sceneById={state.project.scenes}
currentSceneId={state.selectedSceneId}
onCurrentSceneChange={(id) => void actions.selectScene(id)}
onConnect={(sourceGn, targetGn) => void actions.addSceneGraphEdge(sourceGn, targetGn)}
onDisconnect={(edgeId) => void actions.removeSceneGraphEdge(edgeId)}
onNodePositionCommit={(nodeId, x, y) =>
void actions.updateSceneGraphNodePosition(nodeId, x, y)
}
onRemoveGraphNodes={(ids) => {
void Promise.all(ids.map((id) => actions.removeSceneGraphNode(id)));
}}
onRemoveGraphNode={(id) => void actions.removeSceneGraphNode(id)}
onSetGraphNodeStart={(graphNodeId) => void actions.setSceneGraphNodeStart(graphNodeId)}
onDropSceneFromList={(sceneId, x, y) => void actions.addSceneGraphNode(sceneId, x, y)}
/>
) : (
<div className={styles.centerEmpty} />
)}
</div>
}
right={
<div className={styles.editorInspector}>
<div className={styles.inspectorTitle}>Свойства сцены</div>
<div className={styles.inspectorScroll}>
{state.project && state.selectedSceneId ? (
(() => {
const proj = state.project;
const sid = state.selectedSceneId;
const sc = proj.scenes[sid];
return (
<SceneInspector
title={sc?.title ?? ''}
description={sc?.description ?? ''}
previewAssetId={sc?.previewAssetId ?? null}
previewAssetType={sc?.previewAssetType ?? null}
previewVideoAutostart={sc?.previewVideoAutostart ?? false}
previewRotationDeg={sc?.previewRotationDeg ?? 0}
mediaAssets={sceneMediaAssets}
audioRefs={sceneAudioRefs}
onAudioRefsChange={(next) => void actions.updateScene(sid, { media: { audios: next } })}
onPreviewVideoAutostartChange={(next) =>
void actions.updateScene(sid, { previewVideoAutostart: next })
}
onTitleChange={(title) => void actions.updateScene(sid, { title })}
onDescriptionChange={(description) => void actions.updateScene(sid, { description })}
onImportPreview={() => void actions.importScenePreview(sid)}
onClearPreview={() => void actions.clearScenePreview(sid)}
onRotatePreview={(previewRotationDeg) =>
void actions.updateScene(sid, { previewRotationDeg })
}
onUploadMedia={() => void actions.importMediaToScene(sid)}
/>
);
})()
) : (
<div className={styles.muted}>Откройте проект, чтобы редактировать сцену.</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);
if ((licenseSnap?.eulaAcceptedVersion ?? null) === EULA_CURRENT_VERSION) {
setLicenseKeyModalOpen(true);
} else {
setOpenKeyAfterEula(true);
setEulaModalOpen(true);
}
}}
>
Указать ключ
</button>
<button
type="button"
role="menuitem"
className={styles.fileMenuItem}
onClick={() => {
setSettingsMenuOpen(false);
setAboutLicenseOpen(true);
}}
>
О лицензии
</button>
</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}
/>
{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={() => {
setProjectMenuOpen(false);
void actions.closeProject();
}}
>
Начальный экран
</button>
<button
type="button"
role="menuitem"
className={styles.fileMenuItem}
onClick={() => {
setProjectMenuOpen(false);
void actions.importProject();
}}
>
Импорт
</button>
<button
type="button"
role="menuitem"
className={styles.fileMenuItem}
disabled={state.projects.length === 0}
title={state.projects.length === 0 ? 'Нет сохранённых проектов' : undefined}
onClick={() => {
setProjectMenuOpen(false);
setExportModalOpen(true);
}}
>
Экспорт
</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);
}}
>
Переименовать проект
</button>
</div>,
document.body,
)
: null}
{state.project ? (
<RenameProjectModal
open={renameOpen}
projectNameInitial={currentProjectName}
fileBaseNameInitial={currentFileBaseName}
existingProjectNames={existingProjectNames}
existingFileBaseNames={existingFileBaseNames}
onClose={() => setRenameOpen(false)}
onSave={async (name, fileBaseName) => {
await actions.renameProject(name, fileBaseName);
}}
/>
) : null}
<ExportProjectModal
open={exportModalOpen}
projects={state.projects}
initialProjectId={exportModalInitialProjectId}
onClose={() => setExportModalOpen(false)}
onExport={async (projectId) => {
await actions.exportProject(projectId);
}}
/>
</>
);
}
type ExportProjectModalProps = {
open: boolean;
projects: { id: ProjectId; name: string; fileName: string }[];
initialProjectId: ProjectId | null;
onClose: () => void;
onExport: (projectId: ProjectId) => Promise<void>;
};
function ExportProjectModal({
open,
projects,
initialProjectId,
onClose,
onExport,
}: ExportProjectModalProps) {
const [projectId, setProjectId] = useState<ProjectId | null>(initialProjectId);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!open) return;
setProjectId(initialProjectId);
setSaving(false);
setError(null);
}, [initialProjectId, open]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose, open]);
if (!open) return null;
const canExport = projectId !== null && projects.some((p) => p.id === projectId);
return createPortal(
<>
<button type="button" aria-label="Закрыть" onClick={onClose} className={styles.modalBackdrop} />
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>Экспорт проекта</div>
<button type="button" aria-label="Закрыть" onClick={onClose} className={styles.modalClose}>
×
</button>
</div>
<div className={styles.fieldGrid}>
<div className={styles.fieldLabel}>ПРОЕКТ</div>
<select
className={styles.selectInput}
value={projectId ?? ''}
onChange={(e) => setProjectId((e.target.value as ProjectId) || null)}
disabled={projects.length === 0}
>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name} ({p.fileName})
</option>
))}
</select>
<div className={styles.muted}>
Далее откроется окно сохранения: укажите имя и папку для файла .dnd.zip будет создана копия
архива проекта.
</div>
</div>
{error ? <div className={styles.fieldError}>{error}</div> : null}
<div className={styles.modalFooter}>
<Button onClick={onClose} disabled={saving} title={saving ? 'Экспорт…' : undefined}>
Отмена
</Button>
<Button
variant="primary"
disabled={!canExport || saving}
onClick={() => {
if (!projectId || !canExport) return;
void (async () => {
setSaving(true);
setError(null);
try {
await onExport(projectId);
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setSaving(false);
}
})();
}}
>
Сохранить как
</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 [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="Закрыть" onClick={onClose} className={styles.modalBackdrop} />
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>Переименовать проект</div>
<button type="button" aria-label="Закрыть" onClick={onClose} className={styles.modalClose}>
×
</button>
</div>
<div className={styles.fieldGrid}>
<div className={styles.fieldLabel}>НАЗВАНИЕ ПРОЕКТА</div>
<Input value={projectName} onChange={setProjectName} placeholder="Название проекта…" />
{!projectNameOk ? <div className={styles.fieldError}>Минимум 3 символа.</div> : null}
{projectNameDup ? (
<div className={styles.fieldError}>Проект с таким названием уже существует.</div>
) : null}
</div>
<div className={styles.fieldGrid}>
<div className={styles.fieldLabel}>НАЗВАНИЕ ФАЙЛА ПРОЕКТА</div>
<div className={styles.rowFlex}>
<div className={styles.flex1}>
<Input value={fileBaseName} onChange={setFileBaseName} placeholder="my_campaign" />
</div>
<div className={styles.fileSuffix}>.dnd.zip</div>
</div>
{!fileNameOk ? (
<div className={styles.fieldError}>Минимум 3 символа, без символов {'<>:"/\\|?*'}</div>
) : null}
{fileNameDup ? (
<div className={styles.fieldError}>Файл проекта с таким названием уже существует.</div>
) : null}
</div>
{error ? <div className={styles.fieldError}>{error}</div> : null}
<div className={styles.modalFooter}>
<Button onClick={onClose} disabled={saving} title={saving ? 'Сохранение…' : undefined}>
Отмена
</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);
}
})();
}}
>
Сохранить
</Button>
</div>
</div>
</>,
document.body,
);
}
type ProjectPickerProps = {
projects: { id: ProjectId; name: string; updatedAt: string }[];
onCreate: (name: string) => Promise<void>;
onOpen: (id: ProjectId) => Promise<void>;
onDelete: (id: ProjectId) => Promise<void>;
};
function ProjectPicker({ projects, onCreate, onOpen, onDelete }: ProjectPickerProps) {
const [name, setName] = useState('Моя кампания');
const [rowMenuFor, setRowMenuFor] = useState<ProjectId | null>(null);
const [rowMenuPos, setRowMenuPos] = useState<{ left: number; top: number } | null>(null);
useEffect(() => {
if (!rowMenuFor) return;
const onDown = (e: MouseEvent) => {
const t = e.target as HTMLElement | null;
if (!t) return;
if (t.closest('[data-project-row-menu-root="1"]')) return;
setRowMenuFor(null);
setRowMenuPos(null);
};
window.addEventListener('mousedown', onDown);
return () => window.removeEventListener('mousedown', onDown);
}, [rowMenuFor]);
return (
<div className={styles.projectPicker}>
<div className={styles.projectPickerTitle}>Проекты</div>
<div className={styles.projectPickerForm}>
<Input value={name} onChange={setName} placeholder="Название нового проекта…" />
<Button variant="primary" onClick={() => void onCreate(name)}>
Создать проект
</Button>
</div>
<div className={styles.spacer6} />
<div className={styles.sectionLabel}>СУЩЕСТВУЮЩИЕ</div>
<div className={styles.projectListScroll}>
<div className={styles.projectList}>
{projects.map((p) => (
<div key={p.id} className={styles.projectCard}>
<div
className={styles.projectCardBody}
onClick={() => void onOpen(p.id)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') void onOpen(p.id);
}}
>
<div className={styles.projectCardName}>{p.name}</div>
<div className={styles.projectCardMeta}>{new Date(p.updatedAt).toLocaleString('ru-RU')}</div>
</div>
<button
type="button"
className={styles.projectCardMenuBtn}
data-project-row-menu-root="1"
aria-label="Меню проекта"
aria-haspopup="menu"
aria-expanded={rowMenuFor === p.id}
onClick={(e) => {
e.stopPropagation();
const r = e.currentTarget.getBoundingClientRect();
const menuW = 220;
const left = Math.max(8, Math.min(r.right - menuW, window.innerWidth - menuW - 8));
setRowMenuPos({ left, top: r.bottom + 8 });
setRowMenuFor((cur) => (cur === p.id ? null : p.id));
}}
>
</button>
</div>
))}
{projects.length === 0 ? <div className={styles.muted}>Пока нет проектов.</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;
const proj = projects.find((x) => x.id === id);
setRowMenuFor(null);
setRowMenuPos(null);
if (
!id ||
!proj ||
!window.confirm(
`Удалить проект «${proj.name}» безвозвратно? Файл и кэш будут стёрты с диска.`,
)
) {
return;
}
void onDelete(id);
}}
>
Удалить
</button>
</div>,
document.body,
)
: null}
</div>
);
}
type SceneInspectorProps = {
title: string;
description: string;
previewAssetId: AssetId | null;
previewAssetType: 'image' | 'video' | null;
previewVideoAutostart: boolean;
previewRotationDeg: 0 | 90 | 180 | 270;
mediaAssets: MediaAsset[];
audioRefs: SceneAudioRef[];
onAudioRefsChange: (next: SceneAudioRef[]) => void;
onPreviewVideoAutostartChange: (next: boolean) => void;
onTitleChange: (v: string) => void;
onDescriptionChange: (v: string) => void;
onImportPreview: () => void;
onClearPreview: () => void;
onRotatePreview: (deg: 0 | 90 | 180 | 270) => void;
onUploadMedia: () => void;
};
function SceneInspector({
title,
description,
previewAssetId,
previewAssetType,
previewVideoAutostart,
previewRotationDeg,
mediaAssets,
audioRefs,
onAudioRefsChange,
onPreviewVideoAutostartChange,
onTitleChange,
onDescriptionChange,
onImportPreview,
onClearPreview,
onRotatePreview,
onUploadMedia,
}: SceneInspectorProps) {
const previewUrl = useAssetUrl(previewAssetId);
const audioById = useMemo(() => new Map(audioRefs.map((a) => [a.assetId, a])), [audioRefs]);
return (
<div className={styles.sceneInspector}>
<div className={styles.labelSm}>НАЗВАНИЕ СЦЕНЫ</div>
<Input value={title} onChange={onTitleChange} />
<div className={styles.spacer8} />
<div className={styles.labelSm}>ОПИСАНИЕ</div>
<textarea
className={styles.textarea}
value={description}
onChange={(e) => onDescriptionChange(e.target.value)}
/>
<div className={styles.spacer6} />
<div className={styles.labelSm}>ПРЕВЬЮ СЦЕНЫ</div>
<div className={styles.hint}>Отдельный файл изображения (PNG, JPG, WebP, GIF и т.д.).</div>
<div className={styles.previewBox}>
{previewUrl && previewAssetType === 'image' ? (
<RotatedImage url={previewUrl} rotationDeg={previewRotationDeg} mode="cover" />
) : previewUrl && previewAssetType === 'video' ? (
<video
src={previewUrl}
muted
playsInline
autoPlay={previewVideoAutostart}
loop
preload="metadata"
className={styles.videoCover}
/>
) : (
<div className={styles.previewEmpty}>Превью не задано</div>
)}
</div>
<div className={styles.actionsRow}>
<Button variant="primary" onClick={onImportPreview}>
{previewAssetId ? 'Изменить' : 'Загрузить'}
</Button>
{previewAssetId ? <Button onClick={onClearPreview}>Очистить</Button> : null}
{previewAssetId && previewAssetType === 'video' ? (
<label className={styles.checkboxLabel}>
<input
type="checkbox"
checked={previewVideoAutostart}
onChange={(e) => onPreviewVideoAutostartChange(e.target.checked)}
/>
<span className={styles.spanSm}>Автостарт</span>
</label>
) : null}
{previewAssetId && previewAssetType === 'image' ? (
<Button
onClick={() => {
const next = ((previewRotationDeg + 90) % 360) as 0 | 90 | 180 | 270;
onRotatePreview(next);
}}
>
Повернуть
</Button>
) : null}
</div>
<div className={styles.spacer6} />
<div className={styles.labelSm}>АУДИО СЦЕНЫ</div>
<div className={styles.audioDrop}>
{mediaAssets.filter((a) => a.type === 'audio').length === 0 ? (
<div className={[styles.muted, styles.spanSm].join(' ')}>Файлов пока нет. Добавьте аудио.</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}>Авто</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}>Цикл</span>
</label>
<button
type="button"
title="Убрать из сцены"
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}>Загрузить</Button>
</div>
<div className={styles.spacer6} />
<div className={styles.labelSm}>ВЕТВЛЕНИЯ</div>
<div className={styles.hintBlock}>
Перетащите сцену из списка на граф. С одной карточки можно задать несколько вариантов по одной связи
на каждую целевую сцену. Повторно к той же сцене (включая вторую карточку той же сцены на графе)
подключить нельзя.
</div>
</div>
);
}
type SceneListCardProps = {
scene: SceneCard;
onSelect: () => void;
onDeleteScene: (sceneId: SceneId) => void;
};
function SceneListCard({ scene, onSelect, onDeleteScene }: SceneListCardProps) {
const url = 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 : ''].filter(Boolean).join(' ');
return (
<div
draggable
className={cardClass}
onDragStart={(e) => {
e.dataTransfer.setData(DND_SCENE_ID_MIME, scene.id);
e.dataTransfer.effectAllowed = 'copy';
}}
onClick={onSelect}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onSelect();
}}
>
<div className={url ? styles.sceneThumb : styles.sceneThumbEmpty}>
{url && scene.previewAssetType === 'image' ? (
<div className={styles.sceneThumbInner}>
<RotatedImage url={url} rotationDeg={scene.previewRotationDeg} mode="cover" />
</div>
) : url && scene.previewAssetType === 'video' ? (
<div className={styles.sceneThumbInner}>
<video
src={url}
muted
playsInline
preload="metadata"
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}>ТЕКУЩАЯ</div> : null}
<button
type="button"
aria-label="Меню сцены"
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="Закрыть меню"
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={() => {
onDeleteScene(scene.id);
setMenu(null);
}}
>
Удалить
</button>
</div>
</>,
document.body,
)
: null}
</div>
);
}