Files
DndGamePlayer/app/renderer/editor/EditorApp.tsx
T
Ivan Fontosh d94a11d466 Редактор: превью с поворотом, проекты, безопасное сохранение zip, dev-меню
RotatedImage: размер контейнера через clientWidth/Height (не getBoundingClientRect), чтобы cover при 90°/270° работал под zoom React Flow; убраны отладочные логи.

Главное меню в dev: пункт «Вид» с DevTools (Ctrl+Shift+I без пустого application menu).

Список проектов: project.list без лицензии; список подгружается при неактивной лицензии; ProjectPicker с подсказками; listProjects пропускает битые zip.

Сохранение проектов: atomicReplace — замена zip без rm до commit; восстановление *.dnd.zip.tmp при старте; тесты.

EditorApp: блокировка UI при открытых окнах презентации и пульта; стили оверлея.
Made-with: Cursor
2026-04-24 07:04:42 +08:00

1535 lines
57 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, Project, ProjectId, SceneAudioRef, SceneId } from '../../shared/types';
import { AppLogo } from '../shared/branding/AppLogo';
import { getDndApi } from '../shared/dndApi';
import { RotatedImage } from '../shared/RotatedImage';
import { Button, Input } from '../shared/ui/controls';
import { LayoutShell } from '../shared/ui/LayoutShell';
import { useAssetUrl } from '../shared/useAssetImageUrl';
import styles from './EditorApp.module.css';
import { buildNextSceneCardById } from './graph/sceneCardById';
import { DND_SCENE_ID_MIME, SceneGraph, type SceneGraphSceneCard } 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;
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 [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 [previewBusy, setPreviewBusy] = useState(false);
const [presentationOpen, setPresentationOpen] = 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 sceneCardById = useStableSceneCardById(state.project);
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 [];
const createdAtSortKey = (sceneId: string): number => {
// sceneId создаётся как `${prefix}_${rand}_${Date.now().toString(16)}`
const last = sceneId.split('_').at(-1) ?? '';
const n = Number.parseInt(last, 16);
return Number.isFinite(n) ? n : 0;
};
return Object.values(p.scenes)
.map((s) => ({
id: s.id,
title: s.title,
active: s.id === state.selectedSceneId,
previewAssetId: s.previewAssetId,
previewThumbAssetId: s.previewThumbAssetId,
previewAssetType: s.previewAssetType,
previewVideoAutostart: s.previewVideoAutostart,
previewRotationDeg: s.previewRotationDeg,
}))
.sort((a, b) => createdAtSortKey(b.id) - createdAtSortKey(a.id));
}, [state.project, state.selectedSceneId]);
const filtered = useMemo(
() => scenes.filter((s) => s.title.toLowerCase().includes(query.trim().toLowerCase())),
[query, scenes],
);
const sceneMediaAssets = useMemo<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 graphStartSceneId = useMemo(() => {
const p = state.project;
if (!p) return null;
const gn = p.sceneGraphNodes.find((n) => n.isStartScene);
return gn?.sceneId ?? null;
}, [state.project]);
const graphStartGraphNodeId = useMemo(() => {
const p = state.project;
if (!p) return null;
const gn = p.sceneGraphNodes.find((n) => n.isStartScene);
return gn?.id ?? null;
}, [state.project]);
const currentProjectName = state.project?.meta.name ?? '';
const currentFileBaseName = state.project?.meta.fileBaseName ?? '';
const existingProjectNames = useMemo(() => state.projects.map((p) => p.name), [state.projects]);
const existingFileBaseNames = useMemo(() => {
return state.projects.map((p) => p.fileName.replace(/\.dnd\.zip$/iu, ''));
}, [state.projects]);
useEffect(() => {
if (!fileMenuOpen) return;
const r = fileMenuBtnRef.current?.getBoundingClientRect() ?? null;
queueMicrotask(() => {
if (r) {
setFileMenuPos({ left: r.left, top: r.bottom + 10 });
} else {
setFileMenuPos(null);
}
});
const onDown = (e: MouseEvent) => {
const t = e.target as HTMLElement | null;
if (!t) return;
if (t.closest('[data-filemenu-root="1"]')) return;
setFileMenuOpen(false);
};
window.addEventListener('mousedown', onDown);
return () => window.removeEventListener('mousedown', onDown);
}, [fileMenuOpen]);
useEffect(() => {
if (!projectMenuOpen) return;
const r = projectMenuBtnRef.current?.getBoundingClientRect() ?? null;
queueMicrotask(() => {
if (r) {
setProjectMenuPos({ left: r.left, top: r.bottom + 10 });
} else {
setProjectMenuPos(null);
}
});
const onDown = (e: MouseEvent) => {
const t = e.target as HTMLElement | null;
if (!t) return;
if (t.closest('[data-projectmenu-root="1"]')) return;
setProjectMenuOpen(false);
};
window.addEventListener('mousedown', onDown);
return () => window.removeEventListener('mousedown', onDown);
}, [projectMenuOpen]);
useEffect(() => {
if (!settingsMenuOpen) return;
const r = settingsMenuBtnRef.current?.getBoundingClientRect() ?? null;
queueMicrotask(() => {
if (r) {
setSettingsMenuPos({ left: r.left, top: r.bottom + 10 });
} else {
setSettingsMenuPos(null);
}
});
const onDown = (e: MouseEvent) => {
const t = e.target as HTMLElement | null;
if (!t) return;
if (t.closest('[data-settingsmenu-root="1"]')) return;
setSettingsMenuOpen(false);
};
window.addEventListener('mousedown', onDown);
return () => window.removeEventListener('mousedown', onDown);
}, [settingsMenuOpen]);
useEffect(() => {
let off: (() => void) | null = null;
void (async () => {
try {
const snap = await getDndApi().invoke(ipcChannels.windows.getMultiWindowState, {});
setPresentationOpen(snap.open);
} catch {
// ignore
}
off = getDndApi().on(ipcChannels.windows.multiWindowStateChanged, ({ open }) => {
setPresentationOpen(open);
});
})();
return () => {
off?.();
};
}, []);
const reloadLicense = useCallback(() => {
void (async () => {
try {
const s = await getDndApi().invoke(ipcChannels.license.getStatus, {});
setLicenseSnap(s);
} catch {
setLicenseSnap(null);
}
})();
}, []);
useEffect(() => {
reloadLicense();
const unsub = getDndApi().on(ipcChannels.license.statusChanged, () => {
reloadLicense();
});
return unsub;
}, [reloadLicense]);
useEffect(() => {
void (async () => {
try {
const r = await getDndApi().invoke(ipcChannels.app.getVersion, {});
const label = r.buildNumber ? `v${r.version} · ${r.buildNumber}` : `v${r.version}`;
setAppVersionText(label);
} catch {
setAppVersionText(null);
}
})();
}, []);
const exportModalInitialProjectId = state.project?.id ?? state.projects[0]?.id ?? null;
const bodyOverlay =
licenseSnap === null ? (
<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 (
<>
{presentationOpen
? createPortal(
<div className={styles.editorLockOverlay} role="dialog" aria-label="Презентация запущена">
<div className={styles.editorLockModal}>
<div className={styles.editorLockTitle}>Презентация запущена</div>
<div className={styles.editorLockText}>
Редактор заблокирован. Закройте окна «Презентация» и «Панель управления», чтобы продолжить.
</div>
</div>
</div>,
document.body,
)
: null}
{state.zipProgress
? createPortal(
<div className={styles.progressOverlay} role="dialog" aria-label="Прогресс операции">
<div className={styles.progressModal}>
<div className={styles.progressTitle}>
{state.zipProgress.kind === 'import' ? 'Импорт проекта' : 'Экспорт проекта'}
</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}
<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}
licenseActive={licenseActive}
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}
sceneCardById={sceneCardById}
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.inspectorScroll}>
{state.project ? (
<>
<div className={styles.inspectorTitle}>Свойства игры</div>
<CampaignInspector
audioRefs={campaignAudioRefs}
mediaAssets={campaignAudioAssets}
onAudioRefsChange={(next) => void actions.updateCampaignAudios(next)}
onUploadAudio={() => {
void (async () => {
try {
await actions.importCampaignAudio();
} catch (e) {
window.alert(e instanceof Error ? e.message : String(e));
}
})();
}}
/>
<div className={styles.spacer18} />
<div className={styles.inspectorTitle}>Свойства сцены</div>
{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}
previewBusy={previewBusy}
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={() => {
setPreviewBusy(true);
void (async () => {
try {
await actions.importScenePreview(sid);
} catch (e) {
window.alert(e instanceof Error ? e.message : String(e));
} finally {
setPreviewBusy(false);
}
})();
}}
onClearPreview={() => void actions.clearScenePreview(sid)}
onRotatePreview={(previewRotationDeg) =>
void actions.updateScene(sid, { previewRotationDeg })
}
onUploadMedia={() => void actions.importMediaToScene(sid)}
/>
);
})()
) : (
<div className={styles.muted}>Выберите сцену слева, чтобы редактировать её свойства.</div>
)}
</>
) : (
<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 }[];
licenseActive: boolean;
onCreate: (name: string) => Promise<void>;
onOpen: (id: ProjectId) => Promise<void>;
onDelete: (id: ProjectId) => Promise<void>;
};
function ProjectPicker({ projects, licenseActive, 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"
disabled={!licenseActive}
title={!licenseActive ? 'Доступно после активации лицензии' : undefined}
onClick={() => {
if (!licenseActive) return;
void onCreate(name);
}}
>
Создать проект
</Button>
</div>
<div className={styles.spacer6} />
<div className={styles.sectionLabel}>СУЩЕСТВУЮЩИЕ</div>
{!licenseActive && projects.length > 0 ? (
<>
<div className={styles.muted}>
Открытие и создание после активации лицензии. Список показывает файлы в папке приложения.
</div>
<div className={styles.spacer6} />
</>
) : null}
<div className={styles.projectListScroll}>
<div className={styles.projectList}>
{projects.map((p) => (
<div key={p.id} className={styles.projectCard}>
<div
className={styles.projectCardBody}
onClick={() => {
if (!licenseActive) return;
void onOpen(p.id);
}}
role="button"
tabIndex={0}
title={!licenseActive ? 'Открытие проекта — после активации лицензии' : undefined}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
if (!licenseActive) return;
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}
disabled={!licenseActive}
title={!licenseActive ? 'Доступно после активации лицензии' : undefined}
onClick={(e) => {
e.stopPropagation();
if (!licenseActive) return;
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 (async () => {
try {
await onDelete(id);
} catch (e) {
window.alert(e instanceof Error ? e.message : String(e));
}
})();
}}
>
Удалить
</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;
previewBusy: boolean;
mediaAssets: MediaAsset[];
audioRefs: SceneAudioRef[];
onAudioRefsChange: (next: SceneAudioRef[]) => void;
onPreviewVideoAutostartChange: (next: boolean) => void;
onTitleChange: (v: string) => void;
onDescriptionChange: (v: string) => void;
onImportPreview: () => void;
onClearPreview: () => void;
onRotatePreview: (deg: 0 | 90 | 180 | 270) => void;
onUploadMedia: () => void;
};
type CampaignInspectorProps = {
mediaAssets: MediaAsset[];
audioRefs: SceneAudioRef[];
onAudioRefsChange: (next: SceneAudioRef[]) => void;
onUploadAudio: () => void;
};
function CampaignInspector({
mediaAssets,
audioRefs,
onAudioRefsChange,
onUploadAudio,
}: CampaignInspectorProps) {
const audioById = useMemo(() => new Map(audioRefs.map((a) => [a.assetId, a])), [audioRefs]);
return (
<div className={styles.sceneInspector}>
<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={onUploadAudio}>Загрузить</Button>
</div>
</div>
);
}
function SceneInspector({
title,
description,
previewAssetId,
previewAssetType,
previewVideoAutostart,
previewRotationDeg,
previewBusy,
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' ? (
<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}>Превью не задано</div>
)}
{previewBusy ? (
<div className={styles.previewBusyOverlay} aria-live="polite">
<div className={styles.previewBusyModal}>
<div className={styles.previewSpinner} aria-hidden />
<div className={styles.previewBusyText}>Загрузка и оптимизация изображения</div>
</div>
</div>
) : null}
</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 thumbUrl = useAssetUrl(scene.previewThumbAssetId);
const previewUrl = useAssetUrl(scene.previewAssetId);
const [menu, setMenu] = useState<{ x: number; y: number } | null>(null);
useEffect(() => {
if (!menu) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setMenu(null);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [menu]);
const menuPos = useMemo(() => {
if (!menu) return null;
const pad = 8;
const mw = 180;
const mh = 48;
return {
x: Math.max(pad, Math.min(menu.x, window.innerWidth - mw - pad)),
y: Math.max(pad, Math.min(menu.y, window.innerHeight - mh - pad)),
};
}, [menu]);
const cardClass = [styles.sceneCard, scene.active ? styles.sceneCardActive : ''].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={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"
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>
);
}