712086ddca
Показ анимации кубиков игрокам, закрытие оверлеев с окнами, ручной ввод количества, меньший радиус кисти, блок спонсоров в «О программе»; плюс черновик game systems / character sheet. Co-authored-by: Cursor <cursoragent@cursor.com>
191 lines
5.8 KiB
TypeScript
191 lines
5.8 KiB
TypeScript
import React, { useEffect, useState } from 'react';
|
||
|
||
import { ipcChannels } from '../../shared/ipc/contracts';
|
||
import {
|
||
DICE_COUNT_MAX,
|
||
DICE_COUNT_MIN,
|
||
DICE_SIDES,
|
||
clampDiceCount,
|
||
type DiceSides,
|
||
} from '../../shared/types/diceRoll';
|
||
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
||
import { getDndApi } from '../shared/dndApi';
|
||
import { useDiceRollState } from '../shared/dice/useDiceRollState';
|
||
import { Button } from '../shared/ui/controls';
|
||
|
||
import styles from './DiceApp.module.css';
|
||
|
||
export function DiceApp() {
|
||
const { t } = useEditorI18n();
|
||
const api = getDndApi();
|
||
const [state, diceApi] = useDiceRollState();
|
||
const [sides, setSides] = useState<DiceSides>(20);
|
||
const [count, setCount] = useState(1);
|
||
const [countDraft, setCountDraft] = useState('1');
|
||
|
||
useEffect(() => {
|
||
const onKey = (e: KeyboardEvent) => {
|
||
if (e.key === 'Escape') {
|
||
void api.invoke(ipcChannels.windows.closeDice, {});
|
||
}
|
||
};
|
||
window.addEventListener('keydown', onKey);
|
||
return () => window.removeEventListener('keydown', onKey);
|
||
}, [api]);
|
||
|
||
const commitCountDraft = () => {
|
||
const trimmed = countDraft.trim();
|
||
if (trimmed === '') {
|
||
setCount(DICE_COUNT_MIN);
|
||
setCountDraft(String(DICE_COUNT_MIN));
|
||
return;
|
||
}
|
||
const parsed = Number(trimmed);
|
||
if (!Number.isFinite(parsed) || parsed < DICE_COUNT_MIN) {
|
||
setCount(DICE_COUNT_MIN);
|
||
setCountDraft(String(DICE_COUNT_MIN));
|
||
return;
|
||
}
|
||
const next = clampDiceCount(parsed);
|
||
setCount(next);
|
||
setCountDraft(String(next));
|
||
};
|
||
|
||
const setCountClamped = (n: number) => {
|
||
const next = clampDiceCount(n);
|
||
setCount(next);
|
||
setCountDraft(String(next));
|
||
};
|
||
|
||
const showToPlayers = state?.visibleToPlayers ?? true;
|
||
|
||
return (
|
||
<div className={styles.page} data-testid="dice-app">
|
||
<div className={styles.sectionLabel}>{t('dice.sides')}</div>
|
||
<div className={styles.sidesGrid}>
|
||
{DICE_SIDES.map((s) => (
|
||
<Button
|
||
key={s}
|
||
variant={sides === s ? 'primary' : 'ghost'}
|
||
onClick={() => setSides(s)}
|
||
data-testid={`dice-side-${s}`}
|
||
>
|
||
d{s}
|
||
</Button>
|
||
))}
|
||
</div>
|
||
|
||
<div className={styles.sectionLabel}>{t('dice.count')}</div>
|
||
<div className={styles.countRow}>
|
||
<Button
|
||
variant="ghost"
|
||
onClick={() => setCountClamped(count - 1)}
|
||
ariaLabel={t('dice.dec')}
|
||
disabled={count <= DICE_COUNT_MIN}
|
||
>
|
||
−
|
||
</Button>
|
||
<input
|
||
className={styles.countValue}
|
||
type="text"
|
||
inputMode="numeric"
|
||
value={countDraft}
|
||
onChange={(e) => {
|
||
const digits = e.target.value.replace(/\D/g, '');
|
||
if (digits === '') {
|
||
setCountDraft('');
|
||
return;
|
||
}
|
||
const n = Number(digits);
|
||
if (!Number.isFinite(n)) {
|
||
setCountDraft(String(DICE_COUNT_MIN));
|
||
setCount(DICE_COUNT_MIN);
|
||
return;
|
||
}
|
||
if (n > DICE_COUNT_MAX) {
|
||
setCountDraft(String(DICE_COUNT_MAX));
|
||
setCount(DICE_COUNT_MAX);
|
||
return;
|
||
}
|
||
setCountDraft(digits);
|
||
}}
|
||
onBlur={() => commitCountDraft()}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
commitCountDraft();
|
||
(e.currentTarget as HTMLInputElement).blur();
|
||
}
|
||
}}
|
||
aria-label={t('dice.count')}
|
||
data-testid="dice-count"
|
||
/>
|
||
<Button
|
||
variant="ghost"
|
||
onClick={() => setCountClamped(count + 1)}
|
||
ariaLabel={t('dice.inc')}
|
||
disabled={count >= DICE_COUNT_MAX}
|
||
>
|
||
+
|
||
</Button>
|
||
</div>
|
||
|
||
<label className={styles.checkRow}>
|
||
<input
|
||
type="checkbox"
|
||
checked={showToPlayers}
|
||
onChange={(e) =>
|
||
void diceApi.dispatch({ kind: 'setVisibleToPlayers', visible: e.target.checked })
|
||
}
|
||
data-testid="dice-show-to-players"
|
||
/>
|
||
<span>{t('dice.showAnimationToPlayers')}</span>
|
||
</label>
|
||
|
||
<div className={styles.actions}>
|
||
<Button
|
||
variant="primary"
|
||
disabled={clampDiceCount(Number(countDraft.trim()) || count) < 1}
|
||
onClick={() => {
|
||
const next = clampDiceCount(Number(countDraft.trim()));
|
||
setCount(next);
|
||
setCountDraft(String(next));
|
||
if (next < 1) return;
|
||
void diceApi.dispatch({ kind: 'roll', count: next, sides });
|
||
}}
|
||
data-testid="dice-roll"
|
||
>
|
||
{t('dice.roll')}
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
disabled={!state?.values.length && !state?.active}
|
||
onClick={() => void diceApi.dispatch({ kind: 'clear' })}
|
||
>
|
||
{t('dice.clear')}
|
||
</Button>
|
||
</div>
|
||
|
||
{state && state.values.length > 0 ? (
|
||
<div className={styles.lastResult} data-testid="dice-last-result">
|
||
<div className={styles.lastLabel}>{t('dice.result')}</div>
|
||
<div className={styles.lastNotation}>{state.notation}</div>
|
||
<div className={styles.valueChips} aria-label={t('dice.result')}>
|
||
{state.values.map((v, i) => (
|
||
<span key={`${state.revision}-${i}`} className={styles.valueChip}>
|
||
{v}
|
||
</span>
|
||
))}
|
||
</div>
|
||
<div className={styles.lastTotal}>
|
||
{t('dice.total')}: <strong>{state.total}</strong>
|
||
</div>
|
||
{state.active ? <div className={styles.rollingHint}>{t('dice.rolling')}</div> : null}
|
||
</div>
|
||
) : (
|
||
<div className={styles.emptyResult}>{t('dice.noResult')}</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|