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:
@@ -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}
|
||||
>
|
||||
↻
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user