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:
@@ -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,16 +510,10 @@ 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')
|
||||
}
|
||||
disabled={runDisabled}
|
||||
onClick={() => {
|
||||
if (!licenseActive || !graphStartGraphNodeId) return;
|
||||
launchFromGraphNode(graphStartGraphNodeId);
|
||||
@@ -526,6 +521,22 @@ export function EditorApp() {
|
||||
>
|
||||
{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"
|
||||
|
||||
@@ -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 (right‑click a node)',
|
||||
'top.runHelpAria': 'How to enable the Run button',
|
||||
|
||||
'menu.enterKey': 'Enter license key',
|
||||
'menu.aboutLicense': 'About license',
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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<HTMLButtonElement | null>(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(
|
||||
<div role="tooltip" className={styles.tooltip} style={{ left: tipPos.x, top: tipPos.y }}>
|
||||
<div role="tooltip" className={tipClass} style={{ left: tipPos.x, top: tipPos.y }}>
|
||||
{title}
|
||||
</div>,
|
||||
document.body,
|
||||
|
||||
Reference in New Issue
Block a user