Files
Time/src/renderer/components/CountdownPanel.ts
2026-09-12 13:49:44 +08:00

325 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { store } from '../store';
import { createSecondTicker, nextCeilBoundary } from '../secondTick';
import { formatHMS } from '../../shared/time';
import { t } from '../i18n';
import type { CountdownActiveState } from '../../shared/types';
/**
* 通用倒计时面板:
* - idleHH/MM/SS 输入框 + [开始]
* - running圆环 + 剩余时间 + [暂停][重置]
* - paused圆环 + 剩余时间 + [继续][重置]
* - fired瞬时大字"时间到" + [完成]8 秒无操作自动返回)
*
* 输入框变化时idle本地更新 lastDurationMs + 调 setDuration IPC。
* 注意fired 状态由 phaseChange(null) 维持panel 内部 setTimeout 8 秒后自动清除。
*/
export function renderCountdownPanel(root: HTMLElement): () => void {
root.innerHTML = `
<div class="countdown-panel">
<div class="cd-head">
<span class="cd-title">${t('countdown.title')}</span>
<span class="cd-state" id="cd-state" data-state="idle" role="status" aria-live="polite">${t('countdown.stateIdle')}</span>
</div>
<div class="cd-input-row" id="cd-input-row" role="group" aria-label="${t('countdown.ariaSetDuration')}">
<label class="cd-input-wrap">
<input id="cd-input-hh" class="cd-input" type="text" inputmode="numeric" pattern="[0-9]*" maxlength="2" value="0" aria-label="${t('countdown.ariaHours')}" />
<span class="cd-input-unit" aria-hidden="true">${t('countdown.srHours')}</span>
</label>
<span class="cd-colon" aria-hidden="true">:</span>
<label class="cd-input-wrap">
<input id="cd-input-mm" class="cd-input" type="text" inputmode="numeric" pattern="[0-9]*" maxlength="2" value="5" aria-label="${t('countdown.ariaMinutes')}" />
<span class="cd-input-unit" aria-hidden="true">${t('countdown.srMinutes')}</span>
</label>
<span class="cd-colon" aria-hidden="true">:</span>
<label class="cd-input-wrap">
<input id="cd-input-ss" class="cd-input" type="text" inputmode="numeric" pattern="[0-9]*" maxlength="2" value="0" aria-label="${t('countdown.ariaSeconds')}" />
<span class="cd-input-unit" aria-hidden="true">${t('countdown.srSeconds')}</span>
</label>
</div>
<div class="cd-display" role="timer" aria-label="${t('countdown.ariaDisplay')}" id="cd-timer">
<svg width="180" height="180" viewBox="0 0 180 180" aria-hidden="true">
<circle class="ring-bg" cx="90" cy="90" r="80" />
<circle id="cd-ring-fg" class="ring-fg" cx="90" cy="90" r="80"
pathLength="100" transform="rotate(-90 90 90)" />
</svg>
<div class="cd-center">
<div class="cd-time" id="cd-time">00:05:00</div>
<div class="cd-sub" id="cd-sub"></div>
</div>
</div>
<div class="cd-actions" id="cd-actions" data-state="idle">
<button class="btn primary lg" id="cd-start">${t('countdown.startBtn')}</button>
<button class="btn lg" id="cd-pause" style="display:none">${t('countdown.pauseBtn')}</button>
<button class="btn lg" id="cd-resume" style="display:none">${t('countdown.resumeBtn')}</button>
<button class="btn ghost lg" id="cd-reset" style="display:none">${t('countdown.resetBtn')}</button>
</div>
</div>
`;
const inputHh = root.querySelector<HTMLInputElement>('#cd-input-hh')!;
const inputMm = root.querySelector<HTMLInputElement>('#cd-input-mm')!;
const inputSs = root.querySelector<HTMLInputElement>('#cd-input-ss')!;
const inputRow = root.querySelector<HTMLElement>('#cd-input-row')!;
const time = root.querySelector<HTMLElement>('#cd-time')!;
const sub = root.querySelector<HTMLElement>('#cd-sub')!;
const ring = root.querySelector<SVGCircleElement>('#cd-ring-fg')!;
const stateEl = root.querySelector<HTMLElement>('#cd-state')!;
const timerEl = root.querySelector<HTMLElement>('#cd-timer')!;
const actions = root.querySelector<HTMLElement>('#cd-actions')!;
const btnStart = root.querySelector<HTMLButtonElement>('#cd-start')!;
const btnPause = root.querySelector<HTMLButtonElement>('#cd-pause')!;
const btnResume = root.querySelector<HTMLButtonElement>('#cd-resume')!;
const btnReset = root.querySelector<HTMLButtonElement>('#cd-reset')!;
function readDurationMs(): number {
const h = clamp(inputHh.value, 0, 99);
const m = clamp(inputMm.value, 0, 59);
const s = clamp(inputSs.value, 0, 59);
return ((h * 3600) + (m * 60) + s) * 1000;
}
function clamp(raw: string, lo: number, hi: number): number {
const n = Number.parseInt(raw || '0', 10);
if (!Number.isFinite(n)) return lo;
return Math.min(hi, Math.max(lo, n));
}
function syncInputsFromMs(ms: number): void {
const total = Math.max(0, Math.floor(ms / 1000));
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const s = total % 60;
inputHh.value = String(h);
inputMm.value = String(m);
inputSs.value = String(s);
}
function formatHM(totalSec: number): string {
const h = Math.floor(totalSec / 3600);
const m = Math.floor((totalSec % 3600) / 60);
if (h > 0) return t('countdown.fmtHoursMinutes', String(h), String(m));
if (m > 0) return t('countdown.fmtMinutes', String(m));
if (totalSec > 0) return t('countdown.fmtSeconds', String(totalSec));
return t('countdown.inputEmpty');
}
// fired 是 UI-only 的瞬时态:主进程广播 phaseChanged(null) 之后
// active 已为 null靠这个标志把 idle 和 fired 区分开。
let fired = false;
let firedTimer: number | null = null;
function clearFiredTimer(): void {
if (firedTimer !== null) {
window.clearTimeout(firedTimer);
firedTimer = null;
}
}
function exitFired(): void {
clearFiredTimer();
fired = false;
draw();
// exitFired 后 btnReset 被 hidedraw 的 idle 分支),焦点若停留在它上面,
// 浏览器会把焦点挪回 body 而不是 idle 路径里"应该接住"的 btnStart。
// 显式 focus 让键盘/屏读用户后续 Enter 能直接启动,不必先 Tab 找按钮。
btnStart.focus({ preventScroll: true });
}
function enterFired(): void {
fired = true;
clearFiredTimer();
// 8 秒无操作自动回到 idle按钮在本轮 draw 后获得焦点。
firedTimer = window.setTimeout(() => {
firedTimer = null;
exitFired();
}, 8000);
// queueMicrotask 主要是为绕开 sync 路径里 focus 切换被吞的问题;
// 实际上 fired 只在 enterFired 同步路径里被设成 true下面的 focus
// 直接同步调用即可 —— 微任务那一层判断 fired 永远为 true没意义。
btnReset.focus({ preventScroll: true });
}
// 显隐一律走内联 style.display不要用 [hidden] 属性:
// 按钮会被 global.css 的 `.btn[hidden] { display: none }` 锁死,
// timerEl / inputRow 则反过来被自身的 display:flex 盖过 UA 的 [hidden]。
function setVisible(el: HTMLElement, v: boolean): void {
el.style.display = v ? '' : 'none';
}
function draw(): void {
const { active, lastDurationMs } = store.countdown.get();
if (!active) {
syncInputsFromMs(lastDurationMs);
const total = Math.ceil(lastDurationMs / 1000);
time.textContent = formatHMS(fired ? 0 : total);
ring.setAttribute('stroke-dasharray', '0 100');
ring.classList.add('idle');
// 没有 active 就不该呼吸。running → fired 时若不显式清零,
// 上一轮留下的 '1' 会让"时间到"表盘一直脉动。
timerEl.style.setProperty('--ring-pulse', '0');
timerEl.dataset.pulse = '0';
if (fired) {
sub.textContent = t('countdown.timeUp');
stateEl.textContent = t('countdown.timeUp');
stateEl.dataset.state = 'fired';
actions.dataset.state = 'fired';
setVisible(btnStart, false);
setVisible(btnPause, false);
setVisible(btnResume, false);
setVisible(btnReset, true);
btnReset.textContent = t('countdown.doneBtn');
btnReset.classList.remove('ghost');
btnReset.classList.add('primary');
btnReset.disabled = false;
setVisible(timerEl, true); // fired表盘显示"时间到"
inputRow.style.display = 'none';
[inputHh, inputMm, inputSs].forEach(i => { i.disabled = true; });
return;
}
btnReset.textContent = t('countdown.resetBtn');
btnReset.classList.remove('primary');
btnReset.classList.add('ghost');
sub.textContent = t('countdown.setN', formatHM(total));
stateEl.textContent = t('countdown.stateIdle');
stateEl.dataset.state = 'idle';
actions.dataset.state = 'idle';
setVisible(btnStart, true);
setVisible(btnPause, false);
setVisible(btnResume, false);
setVisible(btnReset, false);
setVisible(timerEl, false); // idle表盘整组隐藏避免与输入行重复显示 HH:MM:SS
inputRow.style.display = '';
[inputHh, inputMm, inputSs].forEach(i => { i.disabled = false; });
btnStart.disabled = lastDurationMs <= 0;
return;
}
btnReset.textContent = t('countdown.resetBtn');
btnReset.classList.remove('primary');
btnReset.classList.add('ghost');
const remain = active.paused
? active.remainingMs
: Math.max(0, active.endsAt - Date.now());
const totalSec = Math.ceil(remain / 1000);
time.textContent = formatHMS(totalSec);
sub.textContent = active.paused ? t('countdown.statePaused') : t('countdown.stateRunning');
stateEl.textContent = active.paused ? t('countdown.statePaused') : t('countdown.stateRunning');
stateEl.dataset.state = active.paused ? 'paused' : 'running';
const ratio = Math.max(0, Math.min(1, remain / active.durationMs));
ring.setAttribute('stroke-dasharray', `${ratio * 100} 100`);
ring.classList.remove('idle');
// 暂停时不呼吸;运行中呼吸
timerEl.style.setProperty('--ring-pulse', active.paused ? '0' : '1');
timerEl.dataset.pulse = active.paused ? '0' : '1';
actions.dataset.state = active.paused ? 'paused' : 'running';
setVisible(btnStart, false);
setVisible(btnPause, !active.paused);
setVisible(btnResume, active.paused);
setVisible(btnReset, true);
setVisible(timerEl, true); // running/paused表盘显示
inputRow.style.display = 'none';
[inputHh, inputMm, inputSs].forEach(i => { i.disabled = true; });
// aria 同步:让屏读器知道当前剩余时间(每秒 240ms 节流,无屏读噪音)
timerEl.setAttribute('aria-label',
active.paused ? t('countdown.ariaPausedFmt', formatHM(totalSec)) : t('countdown.ariaRunningFmt', formatHM(totalSec)));
}
// 输入框变化:本地更新 + IPC 持久化 lastDurationMs
// 附带 sanitizetype="text" 允许任意字符,过滤掉非数字;超过 maxlength 自动截断。
function sanitizeInput(el: HTMLInputElement): void {
const cleaned = el.value.replace(/\D/g, '').slice(0, el.maxLength || 2);
if (cleaned !== el.value) el.value = cleaned;
}
function onInputChange(): void {
if (store.countdown.get().active) return; // 运行中不允许改
[inputHh, inputMm, inputSs].forEach(sanitizeInput);
const ms = readDurationMs();
store.countdown.set({ active: null, lastDurationMs: ms });
btnStart.disabled = ms <= 0;
// ms<=0 时不发 IPC主进程会显式拒绝避免「上次时长 = 0」污染持久化
// 未捕获的 promise 拒绝会被 main.ts 的 unhandledrejection handler 弹成 toast
// 把无效输入静默吃掉更友好。start 按钮已在上一行禁用,无法启动。
if (ms > 0) void window.api.countdown.setDuration(ms);
}
// 鼠标滚轮调整数值wheel-up +1wheel-down -1按住 Shift 时步进为 5。
// 运行中active不响应避免误触fired/disabled 状态也跳过 —— 否则 disabled
// 输入框仍会触发 wheel 事件并被 preventDefault 吞掉页面滚动,鼠标停在它上面
// 就划不动页面fired 时三个输入框都是 disabled=true
function onWheelAdjust(e: WheelEvent): void {
const el = e.currentTarget as HTMLInputElement;
if (el.disabled) return;
if (store.countdown.get().active) return;
e.preventDefault();
const max = el === inputHh ? 99 : 59;
const cur = clamp(el.value, 0, max);
const dir = e.deltaY < 0 ? 1 : -1;
const step = e.shiftKey ? 5 : 1;
const next = Math.max(0, Math.min(max, cur + dir * step));
if (next === cur) return;
el.value = String(next);
onInputChange();
}
btnStart.onclick = () => {
const ms = readDurationMs();
if (ms <= 0) return;
void window.api.countdown.start(ms);
};
btnPause.onclick = () => void window.api.countdown.pause();
btnResume.onclick = () => void window.api.countdown.resume();
btnReset.onclick = () => {
if (fired) {
// fired 状态下按"完成"= 用户确认听到了提醒,主进程必须立刻把所有还在跑
// 的提醒路径停掉。当前唯一通道是系统通知cancelAll 已是 no-op
// 保留 IPC 调用是给未来恢复声音/全屏闪时不用再改面板。
void window.api.notifier.cancel();
exitFired();
return;
}
void window.api.countdown.reset();
};
// active 由非 null 变 null 有两种可能:自然归零(fired) 或 用户 reset。
// 区分依据:自然归零时 endsAt 已经到点(<= nowreset 时 endsAt 还在未来。
let prevActive: CountdownActiveState | null = null;
const unsubStore = store.countdown.subscribe(({ active }) => {
if (prevActive && !active
&& !prevActive.paused
&& prevActive.endsAt <= Date.now()) {
enterFired();
}
prevActive = active;
draw();
ticker.restart();
});
// 持续刷新(圆环 + 大字):睡到读数下一次改变,而不是 rAF 60Hz + 240ms 闸门。
// idle显示上次设定时长与 paused 下读数不会自己变nextAt 返回 null不装定时器。
const ticker = createSecondTicker(draw, () => {
const { active } = store.countdown.get();
return !active || active.paused ? null : nextCeilBoundary(active.endsAt);
});
draw();
// 输入框事件 listenercleanup 时一起移除
const inputListeners: Array<[HTMLInputElement, typeof onInputChange]> = [
[inputHh, onInputChange], [inputMm, onInputChange], [inputSs, onInputChange]
];
inputListeners.forEach(([el, fn]) => el.addEventListener('input', fn));
// 滚轮调整HH/MM/SS 都注册 wheel 监听,按目标元素的不同 max 限制HH 0-99、MM/SS 0-59
const wheelListener: typeof onWheelAdjust = onWheelAdjust;
[inputHh, inputMm, inputSs].forEach(el => {
el.addEventListener('wheel', wheelListener, { passive: false });
});
return () => {
ticker.stop();
clearFiredTimer();
inputListeners.forEach(([el, fn]) => el.removeEventListener('input', fn));
[inputHh, inputMm, inputSs].forEach(el => el.removeEventListener('wheel', wheelListener));
unsubStore();
};
}