feat(ui): confirm ending a show and surface help

End-session buttons, Escape, and window close share one confirm. Help is a question-mark icon on the control panel and after the version. About opens directly. Scene editor follows the UI language.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-10-02 08:10:54 +08:00
parent d228b9aab7
commit 060bbd471a
12 changed files with 245 additions and 176 deletions
+32 -34
View File
@@ -38,13 +38,11 @@ import {
SCENE_GRID_SIZE_MAX,
SCENE_GRID_SIZE_MIN,
sceneGridTokenFitFactor,
sceneGridTypeLabelRu,
} from '../../shared/types/sceneGrid';
import {
asSceneTrapId,
DEFAULT_SCENE_TRAP_SIZE_N,
SCENE_TRAP_TYPES,
trapTypeLabelRu,
} from '../../shared/types/sceneTraps';
import { sceneViewPanBy, sceneViewZoomAt } from '../../shared/types/sceneView';
import { USERS_BRANCH_FEATURES_ENABLED } from '../../shared/features/usersBranchFeatures';
@@ -638,17 +636,17 @@ export function SceneEditorApp() {
<div className={styles.page}>
<aside className={styles.sidebar}>
<EllipsisText
text={scene?.title ?? 'Сцена'}
text={scene?.title ?? t('sceneEditor.untitledScene')}
className={[styles.sideTitle, ellipsisStyles.root].join(' ')}
/>
<div className={styles.hint}>
Колесо — зум. СКМ / Space+ЛКМ — пан. Delete — удалить выбранное.
{t('sceneEditor.mapHint')}
</div>
<div className={styles.accordionScroll}>
<div className={styles.accordion}>
<button type="button" className={styles.accordionHead} onClick={() => setGridOpen((v) => !v)}>
Сетка {gridOpen ? '▾' : '▸'}
{t('sceneEditor.grid')} {gridOpen ? '▾' : '▸'}
</button>
{gridOpen ? (
<div className={styles.gridPanel}>
@@ -658,17 +656,17 @@ export function SceneEditorApp() {
checked={localGrid.enabled}
onChange={(e) => persistGrid({ ...localGrid, enabled: e.target.checked })}
/>
<span>Наложить сетку</span>
<span>{t('sceneEditor.enableGrid')}</span>
</label>
<label className={[styles.field, localGrid.enabled ? '' : styles.fieldDisabled].join(' ')}>
<span className={styles.fieldLabel}>Тип</span>
<span className={styles.fieldLabel}>{t('sceneEditor.gridType')}</span>
<Select
disabled={!localGrid.enabled}
value={localGrid.type}
ariaLabel="Тип сетки"
ariaLabel={t('sceneEditor.gridTypeAria')}
options={[
{ value: 'square', label: sceneGridTypeLabelRu('square') },
{ value: 'hex', label: sceneGridTypeLabelRu('hex') },
{ value: 'square', label: t('sceneEditor.gridSquare') },
{ value: 'hex', label: t('sceneEditor.gridHex') },
]}
onChange={(next) =>
persistGrid({
@@ -679,19 +677,19 @@ export function SceneEditorApp() {
/>
</label>
<label className={[styles.field, localGrid.enabled ? '' : styles.fieldDisabled].join(' ')}>
<span className={styles.fieldLabel}>Цвет</span>
<span className={styles.fieldLabel}>{t('sceneEditor.gridColor')}</span>
<input
type="color"
className={styles.colorInput}
disabled={!localGrid.enabled}
value={localGrid.color}
onChange={(e) => persistGrid({ ...localGrid, color: e.target.value })}
aria-label="Цвет сетки"
aria-label={t('sceneEditor.gridColorAria')}
/>
</label>
<label className={[styles.field, localGrid.enabled ? '' : styles.fieldDisabled].join(' ')}>
<span className={styles.fieldLabel}>
Размер <span className={styles.fieldValue}>{Math.round(localGrid.sizeN * 100)}</span>
{t('sceneEditor.gridSize')} <span className={styles.fieldValue}>{Math.round(localGrid.sizeN * 100)}</span>
</span>
<input
type="range"
@@ -833,21 +831,21 @@ export function SceneEditorApp() {
{USERS_BRANCH_FEATURES_ENABLED ? (
<div className={styles.accordion} data-testid="scene-npc-accordion">
<button type="button" className={styles.accordionHead} onClick={() => setNpcsOpen((v) => !v)}>
НПС {npcsOpen ? '▾' : '▸'}
{t('sceneEditor.npcs')} {npcsOpen ? '▾' : '▸'}
</button>
{npcsOpen ? (
<div className={styles.tokensPanel}>
<Input
value={npcSearch}
onChange={setNpcSearch}
placeholder="Поиск НПС…"
placeholder={t('sceneEditor.npcSearch')}
onKeyDown={(e) => e.stopPropagation()}
/>
<div className={styles.tokenGrid}>
{filteredNpcs.map((npc) => (
<NpcPaletteTile key={npc.id} npc={npc} />
))}
{filteredNpcs.length === 0 ? <div className={styles.hint}>Нет НПС</div> : null}
{filteredNpcs.length === 0 ? <div className={styles.hint}>{t('sceneEditor.noNpcs')}</div> : null}
</div>
</div>
) : null}
@@ -856,22 +854,22 @@ export function SceneEditorApp() {
<div className={styles.accordion}>
<button type="button" className={styles.accordionHead} onClick={() => setTokensOpen((v) => !v)}>
Неигровые токены {tokensOpen ? '▾' : '▸'}
{t('sceneEditor.tokens')} {tokensOpen ? '▾' : '▸'}
</button>
{tokensOpen ? (
<div className={styles.tokensPanel}>
<div className={styles.tokensToolbar}>
<Button onClick={() => setTokenModal({ mode: 'create' })}>Добавить</Button>
<Button onClick={() => setTokenModal({ mode: 'create' })}>{t('sceneEditor.add')}</Button>
</div>
<Input
value={tokenSearch}
onChange={setTokenSearch}
placeholder="Поиск…"
placeholder={t('sceneEditor.tokenSearch')}
onKeyDown={(e) => e.stopPropagation()}
/>
<div className={styles.tokenGrid}>
{filteredTokens.length === 0 ? (
<div className={styles.hint}>Нет токенов</div>
<div className={styles.hint}>{t('sceneEditor.noTokens')}</div>
) : (
filteredTokens.map((token) => (
<TokenTile
@@ -889,7 +887,7 @@ export function SceneEditorApp() {
<div className={styles.accordion}>
<button type="button" className={styles.accordionHead} onClick={() => setTrapsOpen((v) => !v)}>
Ловушки {trapsOpen ? '▾' : '▸'}
{t('sceneEditor.traps')} {trapsOpen ? '▾' : '▸'}
</button>
{trapsOpen ? (
<div className={styles.palette}>
@@ -902,10 +900,10 @@ export function SceneEditorApp() {
e.dataTransfer.setData('application/x-dnd-trap-type', type);
e.dataTransfer.effectAllowed = 'copy';
}}
title="Перетащите на карту"
title={t('sceneEditor.dragToMap')}
>
<TrapGlyph type={type} size={22} />
<span className={styles.paletteLabel}>{trapTypeLabelRu(type)}</span>
<span className={styles.paletteLabel}>{t(`sceneEditor.trap.${type}`)}</span>
</div>
))}
</div>
@@ -940,7 +938,7 @@ export function SceneEditorApp() {
});
}}
>
Очистить сцену
{t('sceneEditor.clearScene')}
</Button>
</div>
</div>
@@ -948,7 +946,7 @@ export function SceneEditorApp() {
<div className={styles.stage}>
{!hasMapMedia ? (
<div className={styles.empty}>Нужно изображение или видео сцены</div>
<div className={styles.empty}>{t('sceneEditor.needMedia')}</div>
) : (
<div
ref={hostRef}
@@ -1278,13 +1276,13 @@ export function SceneEditorApp() {
<>
<button
type="button"
aria-label="Закрыть"
aria-label={t('common.close')}
className={editorStyles.modalBackdrop}
onClick={() => setPendingDeleteToken(null)}
/>
<div role="dialog" aria-modal="true" className={editorStyles.modalDialog}>
<div className={editorStyles.modalHeader}>
<div className={editorStyles.modalTitle}>Удалить токен</div>
<div className={editorStyles.modalTitle}>{t('sceneEditor.deleteTokenTitle')}</div>
<button
type="button"
className={editorStyles.modalClose}
@@ -1294,10 +1292,10 @@ export function SceneEditorApp() {
</button>
</div>
<div>
Удалить токен «{pendingDeleteToken.name}» из пула? Он также будет убран с текущей сцены.
{t('sceneEditor.deleteTokenBody', { name: pendingDeleteToken.name })}
</div>
<div className={editorStyles.modalFooter}>
<Button onClick={() => setPendingDeleteToken(null)}>Отмена</Button>
<Button onClick={() => setPendingDeleteToken(null)}>{t('common.cancel')}</Button>
<Button
variant="primary"
onClick={() => {
@@ -1307,7 +1305,7 @@ export function SceneEditorApp() {
persistTokens(tokensRef.current.filter((t) => t.tokenId !== id));
}}
>
Удалить
{t('common.delete')}
</Button>
</div>
</div>
@@ -1345,7 +1343,7 @@ export function SceneEditorApp() {
.catch((err) => console.error('[sceneEditor] openTokenPathEditor', err));
}}
>
Указать движение
{t('sceneEditor.setPath')}
</button>
{(() => {
const tok = localTokens.find((item) => item.id === tokenCtxMenu.placementId);
@@ -1369,7 +1367,7 @@ export function SceneEditorApp() {
setTokenCtxMenu(null);
}}
>
Сбросить на старт пути
{t('sceneEditor.resetPath')}
</button>
);
})()}
@@ -1423,7 +1421,7 @@ export function SceneEditorApp() {
.catch((err) => console.error('[sceneEditor] openTokenPathEditor', err));
}}
>
Указать движение
{t('sceneEditor.setPath')}
</button>
{(() => {
const tok = localNpcTokens.find((item) => item.id === npcCtxMenu.placementId);
@@ -1441,7 +1439,7 @@ export function SceneEditorApp() {
setNpcCtxMenu(null);
}}
>
Сбросить на старт пути
{t('sceneEditor.resetPath')}
</button>
);
})()}