325 lines
15 KiB
TypeScript
325 lines
15 KiB
TypeScript
import { store } from '../store';
|
||
import { createSecondTicker, nextCeilBoundary } from '../secondTick';
|
||
import { formatHMS } from '../../shared/time';
|
||
import { t } from '../i18n';
|
||
import type { CountdownActiveState } from '../../shared/types';
|
||
|
||
/**
|
||
* 通用倒计时面板:
|
||
* - idle:HH/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 被 hide(draw 的 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
|
||
// 附带 sanitize:type="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 +1,wheel-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 已经到点(<= now);reset 时 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();
|
||
|
||
// 输入框事件 listener:cleanup 时一起移除
|
||
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();
|
||
};
|
||
} |