Лицензия, редактор, пульт и сборка

- 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
This commit is contained in:
Ivan Fontosh
2026-04-19 20:11:24 +08:00
parent 5e7dc5ea19
commit 2fa20da94d
40 changed files with 2629 additions and 211 deletions
+179 -9
View File
@@ -1,7 +1,9 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
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';
@@ -12,6 +14,7 @@ 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 = {
@@ -29,13 +32,22 @@ export function EditorApp() {
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 [state, actions] = useProjectState();
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 [];
@@ -134,6 +146,45 @@ export function EditorApp() {
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 {
@@ -148,28 +199,63 @@ export function EditorApp() {
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()}
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);
}}
>
@@ -181,8 +267,12 @@ export function EditorApp() {
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);
}}
>
@@ -200,10 +290,16 @@ export function EditorApp() {
{state.project ? (
<Button
variant="primary"
disabled={!graphStartGraphNodeId}
title={graphStartSceneId ? undefined : 'Назначьте начальную сцену на графе (ПКМ по узлу)'}
disabled={!licenseActive || !graphStartGraphNodeId}
title={
!licenseActive
? 'Доступно после активации лицензии'
: graphStartSceneId
? undefined
: 'Назначьте начальную сцену на графе (ПКМ по узлу)'
}
onClick={() => {
if (!graphStartGraphNodeId) return;
if (!licenseActive || !graphStartGraphNodeId) return;
void (async () => {
await getDndApi().invoke(ipcChannels.project.setCurrentGraphNode, {
graphNodeId: graphStartGraphNodeId,
@@ -319,6 +415,70 @@ export function EditorApp() {
</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
@@ -327,6 +487,17 @@ export function EditorApp() {
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"
@@ -372,7 +543,7 @@ export function EditorApp() {
setRenameOpen(true);
}}
>
Переименовать проект
Переименовать проект
</button>
</div>,
document.body,
@@ -894,7 +1065,6 @@ function SceneInspector({
/>
<span className={styles.spanXs}>Цикл</span>
</label>
<span>аудио</span>
<button
type="button"
title="Убрать из сцены"
@@ -921,7 +1091,7 @@ function SceneInspector({
))}
</div>
)}
<Button onClick={onUploadMedia}>Загрузить аудио</Button>
<Button onClick={onUploadMedia}>Загрузить</Button>
</div>
<div className={styles.spacer6} />
<div className={styles.labelSm}>ВЕТВЛЕНИЯ</div>