Files
Ivan Fontosh db1168a46c feat(control): таймер сессии поверх сцены
Отдельное окно countdown с «Показать игрокам»; на превью — move/resize/rotate, на презентации тот же layout.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-03 16:51:56 +08:00

142 lines
4.4 KiB
TypeScript

import React, { useEffect, useMemo, useState } from 'react';
import { ipcChannels } from '../../shared/ipc/contracts';
import {
DEFAULT_SESSION_TIMER_DURATION_MS,
formatSessionTimerMs,
getSessionTimerRemainingMs,
parseSessionTimerDurationInput,
} from '../../shared/types/sessionTimer';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import { getDndApi } from '../shared/dndApi';
import { useSessionTimerState } from '../shared/sessionTimer/useSessionTimerState';
import { Button, Input } from '../shared/ui/controls';
import styles from './TimerApp.module.css';
const PRESETS_MS = [60_000, 5 * 60_000, 10 * 60_000] as const;
function durationInputFromMs(ms: number): string {
return formatSessionTimerMs(ms);
}
export function TimerApp() {
const { t } = useEditorI18n();
const api = getDndApi();
const [state, timerApi] = useSessionTimerState();
const [now, setNow] = useState(() => Date.now());
const [durationDraft, setDurationDraft] = useState(durationInputFromMs(DEFAULT_SESSION_TIMER_DURATION_MS));
useEffect(() => {
if (!state || state.running) return;
setDurationDraft(durationInputFromMs(state.durationMs));
}, [state]);
useEffect(() => {
if (!state?.running) return;
const id = window.setInterval(() => setNow(Date.now()), 200);
return () => window.clearInterval(id);
}, [state?.running]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
void api.invoke(ipcChannels.windows.closeTimer, {});
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [api]);
const remainingMs = useMemo(() => {
if (!state) return DEFAULT_SESSION_TIMER_DURATION_MS;
return getSessionTimerRemainingMs(state, now);
}, [now, state]);
useEffect(() => {
if (!state?.running) return;
if (remainingMs > 0) return;
void timerApi.dispatch({ kind: 'pause' });
}, [remainingMs, state?.running, timerApi]);
if (!state) {
return <div className={styles.page} data-testid="timer-app" />;
}
const applyDurationDraft = async () => {
const parsed = parseSessionTimerDurationInput(durationDraft);
if (parsed == null) {
setDurationDraft(durationInputFromMs(state.durationMs));
return;
}
await timerApi.dispatch({ kind: 'setDuration', durationMs: parsed });
};
return (
<div className={styles.page} data-testid="timer-app">
<div className={styles.clock} data-expired={remainingMs <= 0 ? '1' : '0'}>
{formatSessionTimerMs(remainingMs)}
</div>
<div className={styles.presets}>
{PRESETS_MS.map((ms) => (
<Button
key={ms}
variant="ghost"
disabled={state.running}
onClick={() => {
setDurationDraft(durationInputFromMs(ms));
void timerApi.dispatch({ kind: 'setDuration', durationMs: ms });
}}
>
{formatSessionTimerMs(ms)}
</Button>
))}
</div>
<label className={styles.field}>
<span className={styles.fieldLabel}>{t('sessionTimer.duration')}</span>
<Input
value={durationDraft}
onChange={setDurationDraft}
onBlur={() => void applyDurationDraft()}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
void applyDurationDraft();
}
}}
/>
</label>
<div className={styles.actions}>
{state.running ? (
<Button variant="primary" onClick={() => void timerApi.dispatch({ kind: 'pause' })}>
{t('sessionTimer.pause')}
</Button>
) : (
<Button variant="primary" onClick={() => void timerApi.dispatch({ kind: 'start' })}>
{t('sessionTimer.start')}
</Button>
)}
<Button variant="ghost" onClick={() => void timerApi.dispatch({ kind: 'reset' })}>
{t('sessionTimer.reset')}
</Button>
</div>
<label className={styles.checkRow}>
<input
type="checkbox"
checked={state.visibleToPlayers}
onChange={(e) =>
void timerApi.dispatch({ kind: 'setVisibleToPlayers', visible: e.target.checked })
}
/>
<span>{t('sessionTimer.showToPlayers')}</span>
</label>
<div className={styles.hint}>{t('sessionTimer.positionHint')}</div>
</div>
);
}