feat(editor): add Run help hint and rename from project list

Show a help icon when Run is disabled, open the relevant instruction section
on click, and position its tooltip below-left. Add Rename project to the home
screen project card menu.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-07-03 22:45:19 +08:00
parent 10bb7013e6
commit de9190959c
5 changed files with 133 additions and 43 deletions
+78 -25
View File
@@ -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<HelpSectionId>('overview');
const [renameOpen, setRenameOpen] = useState(false);
const [exportModalOpen, setExportModalOpen] = useState(false);
const [previewDialogSceneId, setPreviewDialogSceneId] = useState<SceneId | null>(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<SceneGraphUiStrings>(
() => ({
@@ -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}
<div className={styles.headerActions}>
{state.project ? (
<Button
variant="primary"
disabled={!licenseActive || !graphStartGraphNodeId}
title={
!licenseActive
? t('top.afterLicense')
: graphStartSceneId
? undefined
: t('top.setStartScene')
}
onClick={() => {
if (!licenseActive || !graphStartGraphNodeId) return;
launchFromGraphNode(graphStartGraphNodeId);
}}
>
{t('top.run')}
</Button>
<>
<Button
variant="primary"
disabled={runDisabled}
onClick={() => {
if (!licenseActive || !graphStartGraphNodeId) return;
launchFromGraphNode(graphStartGraphNodeId);
}}
>
{t('top.run')}
</Button>
{runDisabled ? (
<Button
variant="ghost"
iconOnly
ariaLabel={t('top.runHelpAria')}
title={runHelpTooltip}
tooltipPlacement="bottom-left"
onClick={() => {
setInstructionsSection(runHelpSection);
setInstructionsOpen(true);
}}
>
?
</Button>
) : null}
</>
) : null}
</div>
</div>
@@ -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}
/>
<AppAboutModal open={appAboutOpen} onClose={() => setAppAboutOpen(false)} appVersion={appVersionText} />
<InstructionsModal open={instructionsOpen} onClose={() => setInstructionsOpen(false)} />
<InstructionsModal
open={instructionsOpen}
initialSection={instructionsSection}
onClose={() => setInstructionsOpen(false)}
/>
{aboutMenuOpen && aboutMenuPos
? createPortal(
<div
@@ -857,6 +887,7 @@ export function EditorApp() {
className={styles.fileMenuItem}
onClick={() => {
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<void>;
onOpen: (id: ProjectId) => Promise<void>;
onRename: (id: ProjectId) => void;
onDelete: (id: ProjectId) => Promise<void>;
};
@@ -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 }}
>
<button
type="button"
role="menuitem"
className={styles.fileMenuItem}
onClick={() => {
const id = rowMenuFor;
setRowMenuFor(null);
setRowMenuPos(null);
if (!id) return;
onRename(id);
}}
>
{t('fileMenu.rename')}
</button>
<button
type="button"
role="menuitem"
+23 -15
View File
@@ -4,7 +4,12 @@ import { createPortal } from 'react-dom';
import { APP_DISPLAY_NAME_EN, APP_DISPLAY_NAME_RU } from '../../../shared/appBranding';
import { Button } from '../../shared/ui/controls';
import styles from '../EditorApp.module.css';
import { HELP_SECTION_IDS, helpSectionBodyKey, helpSectionTitleKey } from '../help/helpSections';
import {
HELP_SECTION_IDS,
helpSectionBodyKey,
helpSectionTitleKey,
type HelpSectionId,
} from '../help/helpSections';
import { useEditorI18n } from '../i18n/EditorI18nContext';
type AppAboutModalProps = {
@@ -90,27 +95,26 @@ export function AppAboutModal({ open, onClose, appVersion }: AppAboutModalProps)
type InstructionsModalProps = {
open: boolean;
onClose: () => void;
initialSection?: HelpSectionId;
};
export function InstructionsModal({ open, onClose }: InstructionsModalProps) {
function InstructionsModalBody({
initialSection,
onClose,
}: {
initialSection: HelpSectionId;
onClose: () => void;
}) {
const { t } = useEditorI18n();
const [activeId, setActiveId] = useState<(typeof HELP_SECTION_IDS)[number]>('overview');
const [activeId, setActiveId] = useState<HelpSectionId>(initialSection);
useEffect(() => {
if (!open) return;
setActiveId('overview');
}, [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;
}, [onClose]);
const body = t(helpSectionBodyKey(activeId));
const paragraphs = body.split('\n\n').filter((p) => p.trim() !== '');
@@ -137,9 +141,7 @@ export function InstructionsModal({ open, onClose }: InstructionsModalProps) {
</div>
<div className={styles.instructionsLayout}>
<div className={styles.instructionsContent}>
<div className={styles.instructionsContentTitle}>
{t(helpSectionTitleKey(activeId))}
</div>
<div className={styles.instructionsContentTitle}>{t(helpSectionTitleKey(activeId))}</div>
{paragraphs.map((p, i) => (
<p key={i} className={styles.instructionsParagraph}>
{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 <InstructionsModalBody key={section} initialSection={section} onClose={onClose} />;
}
@@ -113,6 +113,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'top.run': 'Запустить',
'top.afterLicense': 'Доступно после активации лицензии',
'top.setStartScene': 'Назначьте начальную сцену на графе (ПКМ по узлу)',
'top.runHelpAria': 'Как разблокировать кнопку «Запустить»',
'menu.enterKey': 'Указать ключ',
'menu.aboutLicense': 'О лицензии',
@@ -440,6 +441,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'top.run': 'Run',
'top.afterLicense': 'Available after license activation',
'top.setStartScene': 'Set a start scene on the graph (rightclick a node)',
'top.runHelpAria': 'How to enable the Run button',
'menu.enterKey': 'Enter license key',
'menu.aboutLicense': 'About license',