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 SceneGraphSceneCard,
|
||||||
type SceneGraphUiStrings,
|
type SceneGraphUiStrings,
|
||||||
} from './graph/SceneGraph';
|
} from './graph/SceneGraph';
|
||||||
|
import type { HelpSectionId } from './help/helpSections';
|
||||||
import { useEditorI18n } from './i18n/EditorI18nContext';
|
import { useEditorI18n } from './i18n/EditorI18nContext';
|
||||||
import { EulaModal, LicenseAboutModal, LicenseTokenModal } from './license/EditorLicenseModals';
|
import { EulaModal, LicenseAboutModal, LicenseTokenModal } from './license/EditorLicenseModals';
|
||||||
import type { ProjectNoticeCode } from './state/projectState';
|
import type { ProjectNoticeCode } from './state/projectState';
|
||||||
@@ -88,6 +89,7 @@ export function EditorApp() {
|
|||||||
const [aboutMenuOpen, setAboutMenuOpen] = useState(false);
|
const [aboutMenuOpen, setAboutMenuOpen] = useState(false);
|
||||||
const [appAboutOpen, setAppAboutOpen] = useState(false);
|
const [appAboutOpen, setAppAboutOpen] = useState(false);
|
||||||
const [instructionsOpen, setInstructionsOpen] = useState(false);
|
const [instructionsOpen, setInstructionsOpen] = useState(false);
|
||||||
|
const [instructionsSection, setInstructionsSection] = useState<HelpSectionId>('overview');
|
||||||
const [renameOpen, setRenameOpen] = useState(false);
|
const [renameOpen, setRenameOpen] = useState(false);
|
||||||
const [exportModalOpen, setExportModalOpen] = useState(false);
|
const [exportModalOpen, setExportModalOpen] = useState(false);
|
||||||
const [previewDialogSceneId, setPreviewDialogSceneId] = useState<SceneId | null>(null);
|
const [previewDialogSceneId, setPreviewDialogSceneId] = useState<SceneId | null>(null);
|
||||||
@@ -112,6 +114,7 @@ export function EditorApp() {
|
|||||||
[t],
|
[t],
|
||||||
);
|
);
|
||||||
const [state, actions] = useProjectState(licenseActive, { onNotice: onProjectNotice });
|
const [state, actions] = useProjectState(licenseActive, { onNotice: onProjectNotice });
|
||||||
|
const renameFromPickerRef = useRef(false);
|
||||||
const sceneCardById = useStableSceneCardById(state.project);
|
const sceneCardById = useStableSceneCardById(state.project);
|
||||||
const graphUi = useMemo<SceneGraphUiStrings>(
|
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));
|
return campaignAudioRefs.map((r) => p.assets[r.assetId]).filter((a): a is MediaAsset => Boolean(a));
|
||||||
}, [campaignAudioRefs, state.project]);
|
}, [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 graphStartGraphNodeId = useMemo(() => {
|
||||||
const p = state.project;
|
const p = state.project;
|
||||||
if (!p) return null;
|
if (!p) return null;
|
||||||
@@ -213,6 +210,10 @@ export function EditorApp() {
|
|||||||
return gn?.id ?? null;
|
return gn?.id ?? null;
|
||||||
}, [state.project]);
|
}, [state.project]);
|
||||||
|
|
||||||
|
const runDisabled = !licenseActive || !graphStartGraphNodeId;
|
||||||
|
const runHelpSection: HelpSectionId = !licenseActive ? 'license' : 'graph';
|
||||||
|
const runHelpTooltip = !licenseActive ? t('top.afterLicense') : t('top.setStartScene');
|
||||||
|
|
||||||
const launchFromGraphNode = useCallback(
|
const launchFromGraphNode = useCallback(
|
||||||
(graphNodeId: GraphNodeId) => {
|
(graphNodeId: GraphNodeId) => {
|
||||||
if (!licenseActive) return;
|
if (!licenseActive) return;
|
||||||
@@ -509,16 +510,10 @@ export function EditorApp() {
|
|||||||
) : null}
|
) : null}
|
||||||
<div className={styles.headerActions}>
|
<div className={styles.headerActions}>
|
||||||
{state.project ? (
|
{state.project ? (
|
||||||
|
<>
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
disabled={!licenseActive || !graphStartGraphNodeId}
|
disabled={runDisabled}
|
||||||
title={
|
|
||||||
!licenseActive
|
|
||||||
? t('top.afterLicense')
|
|
||||||
: graphStartSceneId
|
|
||||||
? undefined
|
|
||||||
: t('top.setStartScene')
|
|
||||||
}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!licenseActive || !graphStartGraphNodeId) return;
|
if (!licenseActive || !graphStartGraphNodeId) return;
|
||||||
launchFromGraphNode(graphStartGraphNodeId);
|
launchFromGraphNode(graphStartGraphNodeId);
|
||||||
@@ -526,6 +521,22 @@ export function EditorApp() {
|
|||||||
>
|
>
|
||||||
{t('top.run')}
|
{t('top.run')}
|
||||||
</Button>
|
</Button>
|
||||||
|
{runDisabled ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
iconOnly
|
||||||
|
ariaLabel={t('top.runHelpAria')}
|
||||||
|
title={runHelpTooltip}
|
||||||
|
tooltipPlacement="bottom-left"
|
||||||
|
onClick={() => {
|
||||||
|
setInstructionsSection(runHelpSection);
|
||||||
|
setInstructionsOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
?
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -565,6 +576,21 @@ export function EditorApp() {
|
|||||||
openingProjectId={state.openingProjectId}
|
openingProjectId={state.openingProjectId}
|
||||||
onCreate={actions.createProject}
|
onCreate={actions.createProject}
|
||||||
onOpen={actions.openProject}
|
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}
|
onDelete={actions.deleteProject}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -831,7 +857,11 @@ export function EditorApp() {
|
|||||||
snapshot={licenseSnap}
|
snapshot={licenseSnap}
|
||||||
/>
|
/>
|
||||||
<AppAboutModal open={appAboutOpen} onClose={() => setAppAboutOpen(false)} appVersion={appVersionText} />
|
<AppAboutModal open={appAboutOpen} onClose={() => setAppAboutOpen(false)} appVersion={appVersionText} />
|
||||||
<InstructionsModal open={instructionsOpen} onClose={() => setInstructionsOpen(false)} />
|
<InstructionsModal
|
||||||
|
open={instructionsOpen}
|
||||||
|
initialSection={instructionsSection}
|
||||||
|
onClose={() => setInstructionsOpen(false)}
|
||||||
|
/>
|
||||||
{aboutMenuOpen && aboutMenuPos
|
{aboutMenuOpen && aboutMenuPos
|
||||||
? createPortal(
|
? createPortal(
|
||||||
<div
|
<div
|
||||||
@@ -857,6 +887,7 @@ export function EditorApp() {
|
|||||||
className={styles.fileMenuItem}
|
className={styles.fileMenuItem}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setAboutMenuOpen(false);
|
setAboutMenuOpen(false);
|
||||||
|
setInstructionsSection('overview');
|
||||||
setInstructionsOpen(true);
|
setInstructionsOpen(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -943,7 +974,13 @@ export function EditorApp() {
|
|||||||
fileBaseNameInitial={currentFileBaseName}
|
fileBaseNameInitial={currentFileBaseName}
|
||||||
existingProjectNames={existingProjectNames}
|
existingProjectNames={existingProjectNames}
|
||||||
existingFileBaseNames={existingFileBaseNames}
|
existingFileBaseNames={existingFileBaseNames}
|
||||||
onClose={() => setRenameOpen(false)}
|
onClose={() => {
|
||||||
|
setRenameOpen(false);
|
||||||
|
if (renameFromPickerRef.current) {
|
||||||
|
renameFromPickerRef.current = false;
|
||||||
|
void actions.closeProject();
|
||||||
|
}
|
||||||
|
}}
|
||||||
onSave={async (name, fileBaseName) => {
|
onSave={async (name, fileBaseName) => {
|
||||||
await actions.renameProject(name, fileBaseName);
|
await actions.renameProject(name, fileBaseName);
|
||||||
}}
|
}}
|
||||||
@@ -1538,6 +1575,7 @@ type ProjectPickerProps = {
|
|||||||
openingProjectId: ProjectId | null;
|
openingProjectId: ProjectId | null;
|
||||||
onCreate: (name: string) => Promise<void>;
|
onCreate: (name: string) => Promise<void>;
|
||||||
onOpen: (id: ProjectId) => Promise<void>;
|
onOpen: (id: ProjectId) => Promise<void>;
|
||||||
|
onRename: (id: ProjectId) => void;
|
||||||
onDelete: (id: ProjectId) => Promise<void>;
|
onDelete: (id: ProjectId) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1547,6 +1585,7 @@ function ProjectPicker({
|
|||||||
openingProjectId,
|
openingProjectId,
|
||||||
onCreate,
|
onCreate,
|
||||||
onOpen,
|
onOpen,
|
||||||
|
onRename,
|
||||||
onDelete,
|
onDelete,
|
||||||
}: ProjectPickerProps) {
|
}: ProjectPickerProps) {
|
||||||
const { t, locale } = useEditorI18n();
|
const { t, locale } = useEditorI18n();
|
||||||
@@ -1679,6 +1718,20 @@ function ProjectPicker({
|
|||||||
className={styles.fileMenu}
|
className={styles.fileMenu}
|
||||||
style={{ left: rowMenuPos.left, top: rowMenuPos.top }}
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
|
|||||||
@@ -4,7 +4,12 @@ import { createPortal } from 'react-dom';
|
|||||||
import { APP_DISPLAY_NAME_EN, APP_DISPLAY_NAME_RU } from '../../../shared/appBranding';
|
import { APP_DISPLAY_NAME_EN, APP_DISPLAY_NAME_RU } from '../../../shared/appBranding';
|
||||||
import { Button } from '../../shared/ui/controls';
|
import { Button } from '../../shared/ui/controls';
|
||||||
import styles from '../EditorApp.module.css';
|
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';
|
import { useEditorI18n } from '../i18n/EditorI18nContext';
|
||||||
|
|
||||||
type AppAboutModalProps = {
|
type AppAboutModalProps = {
|
||||||
@@ -90,27 +95,26 @@ export function AppAboutModal({ open, onClose, appVersion }: AppAboutModalProps)
|
|||||||
type InstructionsModalProps = {
|
type InstructionsModalProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
initialSection?: HelpSectionId;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function InstructionsModal({ open, onClose }: InstructionsModalProps) {
|
function InstructionsModalBody({
|
||||||
|
initialSection,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
initialSection: HelpSectionId;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
const { t } = useEditorI18n();
|
const { t } = useEditorI18n();
|
||||||
const [activeId, setActiveId] = useState<(typeof HELP_SECTION_IDS)[number]>('overview');
|
const [activeId, setActiveId] = useState<HelpSectionId>(initialSection);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
|
||||||
setActiveId('overview');
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') onClose();
|
if (e.key === 'Escape') onClose();
|
||||||
};
|
};
|
||||||
window.addEventListener('keydown', onKey);
|
window.addEventListener('keydown', onKey);
|
||||||
return () => window.removeEventListener('keydown', onKey);
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
}, [onClose, open]);
|
}, [onClose]);
|
||||||
|
|
||||||
if (!open) return null;
|
|
||||||
|
|
||||||
const body = t(helpSectionBodyKey(activeId));
|
const body = t(helpSectionBodyKey(activeId));
|
||||||
const paragraphs = body.split('\n\n').filter((p) => p.trim() !== '');
|
const paragraphs = body.split('\n\n').filter((p) => p.trim() !== '');
|
||||||
@@ -137,9 +141,7 @@ export function InstructionsModal({ open, onClose }: InstructionsModalProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className={styles.instructionsLayout}>
|
<div className={styles.instructionsLayout}>
|
||||||
<div className={styles.instructionsContent}>
|
<div className={styles.instructionsContent}>
|
||||||
<div className={styles.instructionsContentTitle}>
|
<div className={styles.instructionsContentTitle}>{t(helpSectionTitleKey(activeId))}</div>
|
||||||
{t(helpSectionTitleKey(activeId))}
|
|
||||||
</div>
|
|
||||||
{paragraphs.map((p, i) => (
|
{paragraphs.map((p, i) => (
|
||||||
<p key={i} className={styles.instructionsParagraph}>
|
<p key={i} className={styles.instructionsParagraph}>
|
||||||
{p}
|
{p}
|
||||||
@@ -175,3 +177,9 @@ export function InstructionsModal({ open, onClose }: InstructionsModalProps) {
|
|||||||
document.body,
|
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.run': 'Запустить',
|
||||||
'top.afterLicense': 'Доступно после активации лицензии',
|
'top.afterLicense': 'Доступно после активации лицензии',
|
||||||
'top.setStartScene': 'Назначьте начальную сцену на графе (ПКМ по узлу)',
|
'top.setStartScene': 'Назначьте начальную сцену на графе (ПКМ по узлу)',
|
||||||
|
'top.runHelpAria': 'Как разблокировать кнопку «Запустить»',
|
||||||
|
|
||||||
'menu.enterKey': 'Указать ключ',
|
'menu.enterKey': 'Указать ключ',
|
||||||
'menu.aboutLicense': 'О лицензии',
|
'menu.aboutLicense': 'О лицензии',
|
||||||
@@ -440,6 +441,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
|||||||
'top.run': 'Run',
|
'top.run': 'Run',
|
||||||
'top.afterLicense': 'Available after license activation',
|
'top.afterLicense': 'Available after license activation',
|
||||||
'top.setStartScene': 'Set a start scene on the graph (right‑click a node)',
|
'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.enterKey': 'Enter license key',
|
||||||
'menu.aboutLicense': 'About license',
|
'menu.aboutLicense': 'About license',
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip {
|
.tooltipTop {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
transform: translate(-50%, calc(-100% - 8px));
|
transform: translate(-50%, calc(-100% - 8px));
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
@@ -39,6 +39,24 @@
|
|||||||
white-space: nowrap;
|
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 {
|
.input {
|
||||||
height: 34px;
|
height: 34px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ type ButtonProps = {
|
|||||||
ariaLabel?: string | undefined;
|
ariaLabel?: string | undefined;
|
||||||
/** Компактная кнопка под одну иконку. */
|
/** Компактная кнопка под одну иконку. */
|
||||||
iconOnly?: boolean;
|
iconOnly?: boolean;
|
||||||
|
/** Позиция тултипа относительно кнопки. */
|
||||||
|
tooltipPlacement?: 'top' | 'bottom-left';
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Button({
|
export function Button({
|
||||||
@@ -23,6 +25,7 @@ export function Button({
|
|||||||
title,
|
title,
|
||||||
ariaLabel,
|
ariaLabel,
|
||||||
iconOnly = false,
|
iconOnly = false,
|
||||||
|
tooltipPlacement = 'top',
|
||||||
}: ButtonProps) {
|
}: ButtonProps) {
|
||||||
const btnRef = useRef<HTMLButtonElement | null>(null);
|
const btnRef = useRef<HTMLButtonElement | null>(null);
|
||||||
const [tipPos, setTipPos] = useState<{ x: number; y: number } | null>(null);
|
const [tipPos, setTipPos] = useState<{ x: number; y: number } | null>(null);
|
||||||
@@ -32,8 +35,12 @@ export function Button({
|
|||||||
const el = btnRef.current;
|
const el = btnRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
const r = el.getBoundingClientRect();
|
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 });
|
setTipPos({ x: r.left + r.width / 2, y: r.top });
|
||||||
}, [disabled, title]);
|
}, [disabled, title, tooltipPlacement]);
|
||||||
|
|
||||||
const hideTip = useCallback(() => {
|
const hideTip = useCallback(() => {
|
||||||
setTipPos(null);
|
setTipPos(null);
|
||||||
@@ -47,10 +54,12 @@ export function Button({
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' ');
|
.join(' ');
|
||||||
|
|
||||||
|
const tipClass = tooltipPlacement === 'bottom-left' ? styles.tooltipBottomLeft : styles.tooltipTop;
|
||||||
|
|
||||||
const tip =
|
const tip =
|
||||||
title && tipPos && typeof document !== 'undefined'
|
title && tipPos && typeof document !== 'undefined'
|
||||||
? createPortal(
|
? 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}
|
{title}
|
||||||
</div>,
|
</div>,
|
||||||
document.body,
|
document.body,
|
||||||
|
|||||||
Reference in New Issue
Block a user