Лицензия, редактор, пульт и сборка
- 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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user