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>
);
})()}
@@ -1,6 +1,7 @@
import React from 'react';
import type { SceneToken } from '../../shared/types';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import { useTokenImageUrl } from '../shared/tokens/useTokenImageUrl';
import styles from './SceneEditorApp.module.css';
@@ -30,6 +31,7 @@ export function SceneTokenMarker({
onResizePointerDown,
onRotatePointerDown,
}: Props) {
const { t } = useEditorI18n();
const url = useTokenImageUrl(token.tokenId);
return (
<div
@@ -41,7 +43,7 @@ export function SceneTokenMarker({
height: sizePx,
transform: `translate(-50%, -50%) rotate(${String(token.rotationDeg)}deg)`,
}}
title="Перетащите · ПКМ — удалить"
title={t('sceneEditor.dragDelete')}
onContextMenu={onContextMenu}
onPointerDown={(e) => {
if (e.button !== 0) return;
@@ -56,8 +58,8 @@ export function SceneTokenMarker({
<button
type="button"
className={styles.rotateHandle}
aria-label="Повернуть"
title="Повернуть"
aria-label={t('sceneEditor.rotate')}
title={t('sceneEditor.rotate')}
onPointerDown={onRotatePointerDown}
>
↻
+17 -15
View File
@@ -12,6 +12,7 @@ import {
} from '../editor/fileDrop';
import editorStyles from '../editor/EditorApp.module.css';
import matStyles from '../editor/MaterialsModals.module.css';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import { getDndApi } from '../shared/dndApi';
import { Button, Input } from '../shared/ui/controls';
import { useTokenImageUrl } from '../shared/tokens/useTokenImageUrl';
@@ -29,6 +30,7 @@ function normalizeName(input: string): string {
}
export function TokenEditModal({ open, initial, existingNames, onClose, onSaved }: Props) {
const { t } = useEditorI18n();
const api = getDndApi();
const [name, setName] = useState('');
const [filePath, setFilePath] = useState<string | null>(null);
@@ -94,26 +96,26 @@ export function TokenEditModal({ open, initial, existingNames, onClose, onSaved
return createPortal(
<>
<button type="button" aria-label="Закрыть" onClick={onClose} className={editorStyles.modalBackdrop} />
<button type="button" aria-label={t('common.close')} onClick={onClose} className={editorStyles.modalBackdrop} />
<div role="dialog" aria-modal="true" className={editorStyles.modalDialog}>
<div className={editorStyles.modalHeader}>
<div className={editorStyles.modalTitle}>
{initial ? 'Изменить токен' : 'Добавить токен'}
{initial ? t('sceneEditor.editToken') : t('sceneEditor.addToken')}
</div>
<button type="button" aria-label="Закрыть" onClick={onClose} className={editorStyles.modalClose}>
<button type="button" aria-label={t('common.close')} onClick={onClose} className={editorStyles.modalClose}>
×
</button>
</div>
<div className={editorStyles.fieldGrid}>
<div className={editorStyles.fieldLabel}>Название</div>
<Input value={name} onChange={setName} placeholder="Название токена" />
{!nameOk ? <div className={editorStyles.fieldError}>Укажите название</div> : null}
{nameDup ? <div className={editorStyles.fieldError}>Такое название уже есть</div> : null}
<div className={editorStyles.fieldLabel}>{t('sceneEditor.tokenName')}</div>
<Input value={name} onChange={setName} placeholder={t('sceneEditor.tokenNamePlaceholder')} />
{!nameOk ? <div className={editorStyles.fieldError}>{t('sceneEditor.tokenNameRequired')}</div> : null}
{nameDup ? <div className={editorStyles.fieldError}>{t('sceneEditor.tokenNameDuplicate')}</div> : null}
</div>
<div className={editorStyles.fieldGrid}>
<div className={editorStyles.fieldLabel}>Изображение</div>
<div className={editorStyles.fieldLabel}>{t('sceneEditor.image')}</div>
<div
className={[matStyles.imageDrop, drop.dragOver ? matStyles.imageDropOver : ''].join(' ')}
onDragEnter={drop.onDragEnter}
@@ -137,12 +139,12 @@ export function TokenEditModal({ open, initial, existingNames, onClose, onSaved
}}
>
{drop.dragOver ? (
<div className={editorStyles.dropHintOverlay}>Отпустите, чтобы загрузить изображение</div>
<div className={editorStyles.dropHintOverlay}>{t('sceneEditor.dropImage')}</div>
) : null}
{previewSrc ? (
<img src={previewSrc} alt="" className={matStyles.previewThumb} draggable={false} />
) : (
<div className={editorStyles.muted}>Перетащите изображение или выберите файл</div>
<div className={editorStyles.muted}>{t('sceneEditor.dropOrPick')}</div>
)}
<Button
onClick={() => {
@@ -153,17 +155,17 @@ export function TokenEditModal({ open, initial, existingNames, onClose, onSaved
})();
}}
>
Выбрать изображение
{t('sceneEditor.chooseImage')}
</Button>
</div>
{!hasImage ? <div className={editorStyles.fieldError}>Нужно изображение</div> : null}
{!hasImage ? <div className={editorStyles.fieldError}>{t('sceneEditor.imageRequired')}</div> : null}
</div>
{error ? <div className={editorStyles.fieldError}>{error}</div> : null}
<div className={editorStyles.modalFooter}>
<Button onClick={onClose} disabled={saving}>
Отмена
{t('common.cancel')}
</Button>
<Button
variant="primary"
@@ -182,14 +184,14 @@ export function TokenEditModal({ open, initial, existingNames, onClose, onSaved
onSaved(token);
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : 'Не удалось сохранить');
setError(e instanceof Error ? e.message : t('sceneEditor.saveFailed'));
} finally {
setSaving(false);
}
})();
}}
>
Сохранить
{t('common.save')}
</Button>
</div>
</div>
+7 -5
View File
@@ -2,6 +2,7 @@ import React, { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import type { AppToken } from '../../shared/types';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import { useTokenImageUrl } from '../shared/tokens/useTokenImageUrl';
import styles from './SceneEditorApp.module.css';
@@ -15,6 +16,7 @@ type Props = {
};
export function TokenTile({ token, onEdit, onDelete }: Props) {
const { t } = useEditorI18n();
const url = useTokenImageUrl(token.id);
const menuBtnRef = useRef<HTMLButtonElement | null>(null);
const menuRef = useRef<HTMLDivElement | null>(null);
@@ -63,7 +65,7 @@ export function TokenTile({ token, onEdit, onDelete }: Props) {
e.dataTransfer.setData(TOKEN_DND_MIME, token.id);
e.dataTransfer.effectAllowed = 'copy';
}}
title="Перетащите на карту"
title={t('sceneEditor.dragToMap')}
>
<div className={styles.tokenThumb}>
{url ? <img src={url} alt="" draggable={false} /> : null}
@@ -74,10 +76,10 @@ export function TokenTile({ token, onEdit, onDelete }: Props) {
ref={menuBtnRef}
type="button"
className={styles.tokenTileMore}
aria-label="Действия"
aria-label={t('sceneEditor.actions')}
aria-haspopup="menu"
aria-expanded={menu !== null}
title="Действия"
title={t('sceneEditor.actions')}
onClick={openMenu}
onPointerDown={(e) => e.stopPropagation()}
>
@@ -102,7 +104,7 @@ export function TokenTile({ token, onEdit, onDelete }: Props) {
onEdit();
}}
>
Изменить
{t('sceneEditor.edit')}
</button>
<button
type="button"
@@ -113,7 +115,7 @@ export function TokenTile({ token, onEdit, onDelete }: Props) {
onDelete();
}}
>
Удалить
{t('common.delete')}
</button>
</div>,
document.body,