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 = `
${t('countdown.title')} ${t('countdown.stateIdle')}
00:05:00
`; const inputHh = root.querySelector('#cd-input-hh')!; const inputMm = root.querySelector('#cd-input-mm')!; const inputSs = root.querySelector('#cd-input-ss')!; const inputRow = root.querySelector('#cd-input-row')!; const time = root.querySelector('#cd-time')!; const sub = root.querySelector('#cd-sub')!; const ring = root.querySelector('#cd-ring-fg')!; const stateEl = root.querySelector('#cd-state')!; const timerEl = root.querySelector('#cd-timer')!; const actions = root.querySelector('#cd-actions')!; const btnStart = root.querySelector('#cd-start')!; const btnPause = root.querySelector('#cd-pause')!; const btnResume = root.querySelector('#cd-resume')!; const btnReset = root.querySelector('#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(); }; }