diff --git a/app/renderer/editor/EditorApp.tsx b/app/renderer/editor/EditorApp.tsx index a91ed05..5c6f0c0 100644 --- a/app/renderer/editor/EditorApp.tsx +++ b/app/renderer/editor/EditorApp.tsx @@ -34,6 +34,7 @@ import { type SceneGraphSceneCard, type SceneGraphUiStrings, } from './graph/SceneGraph'; +import type { HelpSectionId } from './help/helpSections'; import { useEditorI18n } from './i18n/EditorI18nContext'; import { EulaModal, LicenseAboutModal, LicenseTokenModal } from './license/EditorLicenseModals'; import type { ProjectNoticeCode } from './state/projectState'; @@ -88,6 +89,7 @@ export function EditorApp() { const [aboutMenuOpen, setAboutMenuOpen] = useState(false); const [appAboutOpen, setAppAboutOpen] = useState(false); const [instructionsOpen, setInstructionsOpen] = useState(false); + const [instructionsSection, setInstructionsSection] = useState('overview'); const [renameOpen, setRenameOpen] = useState(false); const [exportModalOpen, setExportModalOpen] = useState(false); const [previewDialogSceneId, setPreviewDialogSceneId] = useState(null); @@ -112,6 +114,7 @@ export function EditorApp() { [t], ); const [state, actions] = useProjectState(licenseActive, { onNotice: onProjectNotice }); + const renameFromPickerRef = useRef(false); const sceneCardById = useStableSceneCardById(state.project); const graphUi = useMemo( () => ({ @@ -200,12 +203,6 @@ export function EditorApp() { 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; @@ -213,6 +210,10 @@ export function EditorApp() { return gn?.id ?? null; }, [state.project]); + const runDisabled = !licenseActive || !graphStartGraphNodeId; + const runHelpSection: HelpSectionId = !licenseActive ? 'license' : 'graph'; + const runHelpTooltip = !licenseActive ? t('top.afterLicense') : t('top.setStartScene'); + const launchFromGraphNode = useCallback( (graphNodeId: GraphNodeId) => { if (!licenseActive) return; @@ -509,23 +510,33 @@ export function EditorApp() { ) : null}
{state.project ? ( - + <> + + {runDisabled ? ( + + ) : null} + ) : null}
@@ -565,6 +576,21 @@ export function EditorApp() { openingProjectId={state.openingProjectId} onCreate={actions.createProject} onOpen={actions.openProject} + onRename={(id) => { + renameFromPickerRef.current = true; + void (async () => { + try { + await actions.openProject(id); + setRenameOpen(true); + } catch (e) { + renameFromPickerRef.current = false; + setAppNotice({ + title: t('common.error'), + message: e instanceof Error ? e.message : String(e), + }); + } + })(); + }} onDelete={actions.deleteProject} /> )} @@ -831,7 +857,11 @@ export function EditorApp() { snapshot={licenseSnap} /> setAppAboutOpen(false)} appVersion={appVersionText} /> - setInstructionsOpen(false)} /> + setInstructionsOpen(false)} + /> {aboutMenuOpen && aboutMenuPos ? createPortal(
{ setAboutMenuOpen(false); + setInstructionsSection('overview'); setInstructionsOpen(true); }} > @@ -943,7 +974,13 @@ export function EditorApp() { fileBaseNameInitial={currentFileBaseName} existingProjectNames={existingProjectNames} existingFileBaseNames={existingFileBaseNames} - onClose={() => setRenameOpen(false)} + onClose={() => { + setRenameOpen(false); + if (renameFromPickerRef.current) { + renameFromPickerRef.current = false; + void actions.closeProject(); + } + }} onSave={async (name, fileBaseName) => { await actions.renameProject(name, fileBaseName); }} @@ -1538,6 +1575,7 @@ type ProjectPickerProps = { openingProjectId: ProjectId | null; onCreate: (name: string) => Promise; onOpen: (id: ProjectId) => Promise; + onRename: (id: ProjectId) => void; onDelete: (id: ProjectId) => Promise; }; @@ -1547,6 +1585,7 @@ function ProjectPicker({ openingProjectId, onCreate, onOpen, + onRename, onDelete, }: ProjectPickerProps) { const { t, locale } = useEditorI18n(); @@ -1679,6 +1718,20 @@ function ProjectPicker({ className={styles.fileMenu} style={{ left: rowMenuPos.left, top: rowMenuPos.top }} > +
-
- {t(helpSectionTitleKey(activeId))} -
+
{t(helpSectionTitleKey(activeId))}
{paragraphs.map((p, i) => (

{p} @@ -175,3 +177,9 @@ export function InstructionsModal({ open, onClose }: InstructionsModalProps) { document.body, ); } + +export function InstructionsModal({ open, onClose, initialSection }: InstructionsModalProps) { + if (!open) return null; + const section = initialSection ?? 'overview'; + return ; +} diff --git a/app/renderer/editor/i18n/editorMessages.ts b/app/renderer/editor/i18n/editorMessages.ts index f699dd8..29ab4ad 100644 --- a/app/renderer/editor/i18n/editorMessages.ts +++ b/app/renderer/editor/i18n/editorMessages.ts @@ -113,6 +113,7 @@ export const EDITOR_MESSAGES: Record> = { 'top.run': 'Запустить', 'top.afterLicense': 'Доступно после активации лицензии', 'top.setStartScene': 'Назначьте начальную сцену на графе (ПКМ по узлу)', + 'top.runHelpAria': 'Как разблокировать кнопку «Запустить»', 'menu.enterKey': 'Указать ключ', 'menu.aboutLicense': 'О лицензии', @@ -440,6 +441,7 @@ export const EDITOR_MESSAGES: Record> = { 'top.run': 'Run', 'top.afterLicense': 'Available after license activation', 'top.setStartScene': 'Set a start scene on the graph (right‑click a node)', + 'top.runHelpAria': 'How to enable the Run button', 'menu.enterKey': 'Enter license key', 'menu.aboutLicense': 'About license', diff --git a/app/renderer/shared/ui/Controls.module.css b/app/renderer/shared/ui/Controls.module.css index 009c7f4..c081335 100644 --- a/app/renderer/shared/ui/Controls.module.css +++ b/app/renderer/shared/ui/Controls.module.css @@ -23,7 +23,7 @@ padding: 0 8px; } -.tooltip { +.tooltipTop { position: fixed; transform: translate(-50%, calc(-100% - 8px)); padding: 6px 10px; @@ -39,6 +39,24 @@ white-space: nowrap; } +.tooltipBottomLeft { + position: fixed; + transform: translate(-100%, 8px); + padding: 6px 10px; + border-radius: var(--radius-xs); + font-size: var(--text-xs); + font-weight: 600; + line-height: 1.35; + color: rgba(255, 255, 255, 0.95); + background: var(--color-tooltip-bg); + border: 1px solid var(--stroke-2); + box-shadow: var(--shadow-tooltip); + pointer-events: none; + z-index: var(--z-tooltip); + max-width: min(320px, calc(100vw - 16px)); + white-space: normal; +} + .input { height: 34px; width: 100%; diff --git a/app/renderer/shared/ui/controls.tsx b/app/renderer/shared/ui/controls.tsx index d11718c..cedc309 100644 --- a/app/renderer/shared/ui/controls.tsx +++ b/app/renderer/shared/ui/controls.tsx @@ -13,6 +13,8 @@ type ButtonProps = { ariaLabel?: string | undefined; /** Компактная кнопка под одну иконку. */ iconOnly?: boolean; + /** Позиция тултипа относительно кнопки. */ + tooltipPlacement?: 'top' | 'bottom-left'; }; export function Button({ @@ -23,6 +25,7 @@ export function Button({ title, ariaLabel, iconOnly = false, + tooltipPlacement = 'top', }: ButtonProps) { const btnRef = useRef(null); const [tipPos, setTipPos] = useState<{ x: number; y: number } | null>(null); @@ -32,8 +35,12 @@ export function Button({ const el = btnRef.current; if (!el) return; const r = el.getBoundingClientRect(); + if (tooltipPlacement === 'bottom-left') { + setTipPos({ x: r.right, y: r.bottom }); + return; + } setTipPos({ x: r.left + r.width / 2, y: r.top }); - }, [disabled, title]); + }, [disabled, title, tooltipPlacement]); const hideTip = useCallback(() => { setTipPos(null); @@ -47,10 +54,12 @@ export function Button({ .filter(Boolean) .join(' '); + const tipClass = tooltipPlacement === 'bottom-left' ? styles.tooltipBottomLeft : styles.tooltipTop; + const tip = title && tipPos && typeof document !== 'undefined' ? createPortal( -

+
{title}
, document.body,