update
This commit is contained in:
109
src/renderer/App.ts
Normal file
109
src/renderer/App.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { store, bootstrap } from './store';
|
||||
import { renderTopbar } from './components/Topbar';
|
||||
import { renderTabStrip } from './components/TabStrip';
|
||||
import { renderClockPanel } from './components/ClockPanel';
|
||||
import { renderAlarmList } from './components/AlarmList';
|
||||
import { renderPomodoroPanel } from './components/PomodoroPanel';
|
||||
import { renderCountdownPanel } from './components/CountdownPanel';
|
||||
import { renderSettingsPanel } from './components/SettingsPanel';
|
||||
import { mountToastHost, toast } from './components/Toast';
|
||||
import { applyTheme, disposeTheme } from './themeApply';
|
||||
import { t } from './i18n';
|
||||
import type { Route } from '../shared/types';
|
||||
|
||||
type PanelRender = (root: HTMLElement) => () => void;
|
||||
|
||||
const PANELS: Record<Route, PanelRender> = {
|
||||
clock: renderClockPanel,
|
||||
alarms: renderAlarmList,
|
||||
pomodoro: renderPomodoroPanel,
|
||||
countdown: renderCountdownPanel,
|
||||
settings: renderSettingsPanel
|
||||
};
|
||||
|
||||
/** Route 白名单:openRoute IPC 收到的 route 必须是这里的一员才能设置到 store。
|
||||
* 否则 store 会接受任意字符串,与上游 Route 类型契约不一致(SP-1 修复)。
|
||||
* 表里维护一份即可,与 PANELS 的 key 同步类型保证。 */
|
||||
const KNOWN_ROUTES = new Set<Route>(Object.keys(PANELS) as Route[]);
|
||||
|
||||
// window 级 error / unhandledrejection 在 main.ts 入口处就挂好了(早于 mount),
|
||||
// 这里不再 addEventListener —— 重复挂会触发两份 toast。
|
||||
|
||||
export async function mount(root: HTMLElement): Promise<void> {
|
||||
// bootstrap 走顶部静态 import:store.ts 本来就被本文件和一堆组件静态引用,
|
||||
// 再动态 import 一次并不会拆出独立 chunk,只会让 Vite 报
|
||||
// "dynamically imported by ... but also statically imported" 的告警。
|
||||
await bootstrap();
|
||||
applyTheme();
|
||||
|
||||
// 通知权限:主进程自己用 Electron Notification 不需要渲染端授权,
|
||||
// 但设置面板的「试听通知」走 window.Notification(Web Notification API),
|
||||
// 必须先请求权限,否则 Notification.permission === 'default' 时 new Notification
|
||||
// 会被 Chromium 直接吞掉。请求幂等:已 granted/denied 时立刻 resolve。
|
||||
if ('Notification' in window && Notification.permission === 'default') {
|
||||
void Notification.requestPermission().catch(() => { /* 用户拒绝不影响其它行为 */ });
|
||||
}
|
||||
|
||||
root.innerHTML = `
|
||||
<a class="skip-link" href="#route-panel-wrap">Skip to main content</a>
|
||||
<main class="app-shell">
|
||||
<div class="app-content">
|
||||
<header id="topbar" class="topbar"></header>
|
||||
<nav id="tab-strip" class="tab-strip"></nav>
|
||||
<section id="route-panel-wrap" class="route-panel-wrap" role="main" tabindex="-1">
|
||||
<div id="route-panel" class="route-panel"></div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
`;
|
||||
const topbar = root.querySelector<HTMLElement>('#topbar')!;
|
||||
const tabStrip = root.querySelector<HTMLElement>('#tab-strip')!;
|
||||
const panelWrap = root.querySelector<HTMLElement>('#route-panel')!;
|
||||
renderTopbar(topbar);
|
||||
renderTabStrip(tabStrip);
|
||||
// mountToastHost 内部幂等(main.ts 已在 document.body 上预挂一次 host,
|
||||
// 这里再调用就是 no-op);不重复挂载避免一份 toast 在 4 处同时弹。
|
||||
mountToastHost(root);
|
||||
|
||||
// 每次 mount() 自己持有一次性订阅,返回时(目前未返回)统一解绑。
|
||||
// 当前 mount 是单次入口,不会重复;这里收集只是给 HMR / 测试一个退出点。
|
||||
const localCleanups: Array<() => void> = [];
|
||||
localCleanups.push(window.api.on.storageError((s) => {
|
||||
toast(s.message || t('error.unknown'), 'error');
|
||||
}));
|
||||
localCleanups.push(window.api.on.openRoute((route) => {
|
||||
// 主进程在托盘右键 / 单实例唤醒时让渲染端切到指定面板。
|
||||
// 运行时白名单校验:未知值要丢弃,而非 fallback 到 'clock'(免得
|
||||
// 用户不小心点了"打开主窗"就被拽到不存在的面板)。
|
||||
if (!KNOWN_ROUTES.has(route)) return;
|
||||
store.route.set(route);
|
||||
}));
|
||||
|
||||
let cleanupCurrent: (() => void) | null = null;
|
||||
function sync(): void {
|
||||
cleanupCurrent?.();
|
||||
cleanupCurrent = null;
|
||||
// replaceChildren 比 innerHTML='' + appendChild 便宜:不会触发 HTML 解析器,
|
||||
// 只走 DOM 移除路径。切面板属于高频低代价操作,但用户频繁切(看状态卡)时
|
||||
// 也能避免 innerHTML setter 内置的安全检查开销。
|
||||
panelWrap.replaceChildren();
|
||||
const div = document.createElement('div');
|
||||
div.className = 'route-panel';
|
||||
panelWrap.appendChild(div);
|
||||
cleanupCurrent = PANELS[store.route.get()](div) ?? null;
|
||||
}
|
||||
// 先订阅再 sync:之后推送的 storage 变化能直接触发 sync;
|
||||
// 之前是先 sync 后 subscribe,如果在两次之间有 storage 推过来就会漏掉。
|
||||
localCleanups.push(store.route.subscribe(sync));
|
||||
sync();
|
||||
|
||||
// 暴露给 HMR / 测试(实际无人调用,但保留以备扩展)。
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.dispose(() => {
|
||||
localCleanups.forEach(fn => fn());
|
||||
cleanupCurrent?.();
|
||||
disposeTheme();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
262
src/renderer/components/AlarmEditor.ts
Normal file
262
src/renderer/components/AlarmEditor.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import { store } from '../store';
|
||||
import { escapeHtml } from './escape';
|
||||
import { toast } from './Toast';
|
||||
import { t } from '../i18n';
|
||||
import { formatHHMMSS, parseHHmm } from '../../shared/time';
|
||||
|
||||
/**
|
||||
* 极简闹钟编辑器:仅暴露时间 + 标签。
|
||||
* 提醒方式统一为系统通知,Alarm 类型本身不再带声音/全屏闪字段。
|
||||
* 编辑只改 label / time,不覆盖其它字段。
|
||||
*
|
||||
* 由 AlarmList 内联调用 —— `onClose` 替代了原来 `window.close()` 的角色:
|
||||
* 新建/编辑完成后由调用方负责收起编辑器。
|
||||
*
|
||||
* 返回 cleanup:调用方(AlarmList.setEditing)在收起编辑器时必须调一下,
|
||||
* 否则:1) 4s revertTimer 还会跑 leaveConfirming() 戳游离节点;
|
||||
* 2) wheel / input listener 还在 detached DOM 上挂着 → 内存占用 + GC 推迟。
|
||||
*/
|
||||
export async function mount(root: HTMLElement, alarmId: string | null, onClose: () => void): Promise<() => void> {
|
||||
const existing = alarmId ? store.alarms.get().find(a => a.id === alarmId) : null;
|
||||
if (!store.settings.get()) {
|
||||
root.innerHTML = `<main class="app app-editor"><p class="editor-error">${t('editor.settingsLoading')}</p></main>`;
|
||||
return () => { /* nothing to clean up */ };
|
||||
}
|
||||
const isNew = !existing;
|
||||
const draft = {
|
||||
label: existing?.label ?? t('editor.defaultLabel'),
|
||||
time: existing?.time ?? '08:00',
|
||||
// 老闹钟没有 repeat 字段 → 视为 'once',与主进程 normalize 一致。
|
||||
repeat: (existing?.repeat === 'daily' ? 'daily' : 'once') as 'once' | 'daily'
|
||||
};
|
||||
// 从现有 time 字符串拆出时/分/秒;秒缺省视为 0。新建时默认值 08:00 → (8, 0, 0)。
|
||||
const initialParts = parseHHmm(draft.time) ?? { h: 8, m: 0, s: 0 };
|
||||
const hh0 = String(initialParts.h).padStart(2, '0');
|
||||
const mm0 = String(initialParts.m).padStart(2, '0');
|
||||
const ss0 = String(initialParts.s).padStart(2, '0');
|
||||
|
||||
root.innerHTML = `
|
||||
<main class="app app-editor">
|
||||
<header class="editor-header">
|
||||
<h2>${isNew ? t('editor.newTitle') : t('editor.editTitle')}</h2>
|
||||
</header>
|
||||
<div class="field-row field-row-time">
|
||||
<label class="ed-time-label">${t('editor.time')}</label>
|
||||
<div class="ed-time-row" role="group" aria-label="${escapeHtml(t('editor.ariaSetTime'))}">
|
||||
<label class="ed-time-wrap">
|
||||
<input id="ed-time-hh" class="ed-time-input" type="text" inputmode="numeric" pattern="[0-9]*" maxlength="2" value="${hh0}" aria-label="${escapeHtml(t('editor.ariaHours'))}" autofocus />
|
||||
<span class="ed-time-unit" aria-hidden="true">${t('editor.srHours')}</span>
|
||||
</label>
|
||||
<span class="ed-colon" aria-hidden="true">:</span>
|
||||
<label class="ed-time-wrap">
|
||||
<input id="ed-time-mm" class="ed-time-input" type="text" inputmode="numeric" pattern="[0-9]*" maxlength="2" value="${mm0}" aria-label="${escapeHtml(t('editor.ariaMinutes'))}" />
|
||||
<span class="ed-time-unit" aria-hidden="true">${t('editor.srMinutes')}</span>
|
||||
</label>
|
||||
<span class="ed-colon" aria-hidden="true">:</span>
|
||||
<label class="ed-time-wrap">
|
||||
<input id="ed-time-ss" class="ed-time-input" type="text" inputmode="numeric" pattern="[0-9]*" maxlength="2" value="${ss0}" aria-label="${escapeHtml(t('editor.ariaSeconds'))}" />
|
||||
<span class="ed-time-unit" aria-hidden="true">${t('editor.srSeconds')}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label for="f-label">${t('editor.label')}</label>
|
||||
<input id="f-label" type="text" value="${escapeHtml(draft.label)}" placeholder="${t('editor.labelPh')}" maxlength="40" />
|
||||
</div>
|
||||
<div class="field-row field-row-repeat">
|
||||
<label class="ed-repeat" for="f-repeat">
|
||||
<span>${t('editor.repeat')}</span>
|
||||
<input id="f-repeat" type="checkbox" ${draft.repeat === 'daily' ? 'checked' : ''} />
|
||||
<span class="ed-repeat-track" aria-hidden="true"><span class="ed-repeat-thumb"></span></span>
|
||||
<span class="ed-repeat-text">${t(draft.repeat === 'daily' ? 'editor.repeatDaily' : 'editor.repeatOnce')}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="actions" data-confirming="false">
|
||||
${existing ? `
|
||||
<button id="del" class="btn danger del-btn">${t('editor.deleteBtn')}</button>
|
||||
<button id="del-cancel" class="btn ghost del-cancel" hidden>${t('common.cancel')}</button>
|
||||
<button id="del-confirm" class="btn danger del-confirm" hidden>${t('alarms.confirmDelete')}</button>
|
||||
` : '<span></span>'}
|
||||
<button id="cancel" class="btn ghost">${t('editor.cancel')}</button>
|
||||
<button id="save" class="btn primary">${t('editor.save')}</button>
|
||||
</div>
|
||||
</main>
|
||||
`;
|
||||
|
||||
const $ = <T extends HTMLElement = HTMLElement>(id: string) => root.querySelector<T>(`#${id}`)!;
|
||||
|
||||
// 鼠标滚轮调整时间:HH/MM/SS 三个分段独立调;wheel-up +1、wheel-down -1;
|
||||
// 按住 Shift 时步进为 5。运行/暂停态不存在(编辑器始终可编辑),无需 active 守卫。
|
||||
const inputHh = $<HTMLInputElement>('ed-time-hh');
|
||||
const inputMm = $<HTMLInputElement>('ed-time-mm');
|
||||
const inputSs = $<HTMLInputElement>('ed-time-ss');
|
||||
|
||||
function clamp(raw: string, lo: number, hi: number): number {
|
||||
const n = parseInt(raw || '0', 10);
|
||||
if (!Number.isFinite(n)) return lo;
|
||||
return Math.min(hi, Math.max(lo, n));
|
||||
}
|
||||
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 onWheelAdjust(e: WheelEvent): void {
|
||||
e.preventDefault();
|
||||
const el = e.currentTarget as HTMLInputElement;
|
||||
const max = el === inputHh ? 23 : 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);
|
||||
}
|
||||
function onInputSanitize(): void {
|
||||
sanitizeInput(inputHh);
|
||||
sanitizeInput(inputMm);
|
||||
sanitizeInput(inputSs);
|
||||
}
|
||||
// 收集 listener 注册 + revertTimer 清理由 cleanup 统一收口 —— 见 setEditing 调用。
|
||||
let deleteRevertTimer: number | null = null;
|
||||
const inputListeners: Array<[HTMLInputElement, 'input' | 'wheel']> = [];
|
||||
[inputHh, inputMm, inputSs].forEach(el => {
|
||||
el.addEventListener('input', onInputSanitize);
|
||||
el.addEventListener('wheel', onWheelAdjust, { passive: false });
|
||||
inputListeners.push([el, 'input'], [el, 'wheel']);
|
||||
});
|
||||
|
||||
// 暴露给 AlarmList 的 cleanup:mount 收到的回调(setEditing(null))在编辑器收起时触发。
|
||||
// 旧实现没有 cleanup → 4s revertTimer 会戳游离 DOM;wheel/input listener 也挂着。
|
||||
const cleanup = (): void => {
|
||||
if (deleteRevertTimer !== null) {
|
||||
window.clearTimeout(deleteRevertTimer);
|
||||
deleteRevertTimer = null;
|
||||
}
|
||||
for (const [el, type] of inputListeners) {
|
||||
if (type === 'input') el.removeEventListener('input', onInputSanitize);
|
||||
else el.removeEventListener('wheel', onWheelAdjust);
|
||||
}
|
||||
inputListeners.length = 0;
|
||||
};
|
||||
|
||||
// 把 cleanup 绑到 onClose:调用方收编辑器时主动解除 listener + 清 timer。
|
||||
// 旧实现没有 cleanup —— 4s revertTimer 还在跑、listener 还在 detached DOM 上挂着。
|
||||
const originalOnClose = onClose;
|
||||
let cleaned = false;
|
||||
const wrappedClose = (): void => {
|
||||
if (cleaned) return;
|
||||
cleaned = true;
|
||||
cleanup();
|
||||
originalOnClose();
|
||||
};
|
||||
|
||||
$('save').onclick = async () => {
|
||||
const saveBtn = $<HTMLButtonElement>('save');
|
||||
const label = $<HTMLInputElement>('f-label').value.trim();
|
||||
// 拼时/分/秒:每段单独 clamp 后用 formatHHMMSS 序列化;秒为 0 时自动省略 :ss
|
||||
const hh = clamp(inputHh.value, 0, 23);
|
||||
const mm = clamp(inputMm.value, 0, 59);
|
||||
const ss = clamp(inputSs.value, 0, 59);
|
||||
const time = formatHHMMSS({ h: hh, m: mm, s: ss });
|
||||
if (!parseHHmm(time)) { toast(t('editor.timeInvalid'), 'warn'); return; }
|
||||
// 重复模式:勾选 = 每日;未勾选 = 单次。
|
||||
const repeat: 'once' | 'daily' = $<HTMLInputElement>('f-repeat').checked ? 'daily' : 'once';
|
||||
// 提醒方式已统一为系统通知,编辑只改 label / time / repeat,不动其它字段。
|
||||
saveBtn.disabled = true;
|
||||
saveBtn.textContent = t('editor.saving');
|
||||
try {
|
||||
if (existing) {
|
||||
await window.api.alarms.update(existing.id, { label, time, repeat });
|
||||
} else {
|
||||
await window.api.alarms.add({
|
||||
label,
|
||||
time,
|
||||
enabled: true,
|
||||
repeat
|
||||
});
|
||||
}
|
||||
wrappedClose();
|
||||
} catch (e) {
|
||||
// 用户在 await 期间点了"取消"会触发 wrappedClose,cleaned=true 此时编辑器
|
||||
// 已经从 DOM 卸载,saveBtn 已游离 —— 再去 disabled=false / textContent='保存'
|
||||
// 是无害的但会动一个 detached 节点,触发无效 mutation record。这里判断一下跳过。
|
||||
toast(t('editor.saveFailedPrefix') + errorMessage(e), 'error');
|
||||
if (!cleaned) {
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.textContent = t('editor.save');
|
||||
}
|
||||
}
|
||||
};
|
||||
$('cancel').onclick = wrappedClose;
|
||||
|
||||
// 重复 toggle 切换时同步更新"一次/每日"文案。toggle 用 native checkbox
|
||||
// 避免引入额外控件,样式由 .ed-repeat-track / .ed-repeat-thumb 走 CSS 渲染。
|
||||
const repeatInput = $<HTMLInputElement>('f-repeat');
|
||||
const repeatText = root.querySelector<HTMLElement>('.ed-repeat-text')!;
|
||||
repeatInput.addEventListener('change', () => {
|
||||
repeatText.textContent = repeatInput.checked
|
||||
? t('editor.repeatDaily')
|
||||
: t('editor.repeatOnce');
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
// 内联删除确认:第一次点"删除"→ actions 切到"取消 / 确认删除",4s 内不点
|
||||
// 就自动撤回。比原生 confirm() 更轻,也不打断用户的视线焦点。
|
||||
const actionsEl = root.querySelector<HTMLElement>('.actions')!;
|
||||
const delBtn = $<HTMLButtonElement>('del');
|
||||
const cancelBtn = $<HTMLButtonElement>('del-cancel');
|
||||
const confirmBtn = $<HTMLButtonElement>('del-confirm');
|
||||
let revertTimer: number | null = null;
|
||||
const clearTimer = (): void => {
|
||||
if (revertTimer !== null) {
|
||||
window.clearTimeout(revertTimer);
|
||||
revertTimer = null;
|
||||
deleteRevertTimer = null;
|
||||
}
|
||||
};
|
||||
const enterConfirming = (): void => {
|
||||
actionsEl.dataset.confirming = 'true';
|
||||
delBtn.hidden = true;
|
||||
cancelBtn.hidden = false;
|
||||
confirmBtn.hidden = false;
|
||||
clearTimer();
|
||||
revertTimer = window.setTimeout(() => {
|
||||
revertTimer = null;
|
||||
deleteRevertTimer = null;
|
||||
leaveConfirming();
|
||||
}, 4000);
|
||||
deleteRevertTimer = revertTimer;
|
||||
};
|
||||
const leaveConfirming = (): void => {
|
||||
actionsEl.dataset.confirming = 'false';
|
||||
delBtn.hidden = false;
|
||||
cancelBtn.hidden = true;
|
||||
confirmBtn.hidden = true;
|
||||
clearTimer();
|
||||
};
|
||||
delBtn.onclick = () => {
|
||||
if (actionsEl.dataset.confirming === 'true') leaveConfirming();
|
||||
else enterConfirming();
|
||||
};
|
||||
cancelBtn.onclick = () => leaveConfirming();
|
||||
confirmBtn.onclick = async () => {
|
||||
confirmBtn.disabled = true;
|
||||
try {
|
||||
await window.api.alarms.remove(existing.id);
|
||||
wrappedClose();
|
||||
} catch (e) {
|
||||
toast(t('editor.deleteFailedPrefix') + errorMessage(e), 'error');
|
||||
confirmBtn.disabled = false;
|
||||
// 出错留在编辑器里,listener 不释放,留给下次点。
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return cleanup;
|
||||
}
|
||||
|
||||
function errorMessage(e: unknown): string {
|
||||
if (e instanceof Error) return e.message;
|
||||
if (typeof e === 'string') return e;
|
||||
return t('error.unknown');
|
||||
}
|
||||
264
src/renderer/components/AlarmList.ts
Normal file
264
src/renderer/components/AlarmList.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import { store } from '../store';
|
||||
import type { Alarm } from '../../shared/types';
|
||||
import { escapeHtml } from './escape';
|
||||
import { mount as mountAlarmEditor } from './AlarmEditor';
|
||||
import { t } from '../i18n';
|
||||
|
||||
/** 列表里闹钟副标题:直接展示重复模式。行为通道统一为通知,没必要再让用户看。 */
|
||||
function fmtSubtitle(a: Alarm): string {
|
||||
return a.repeat === 'daily' ? t('alarms.repeatDaily') : t('alarms.repeatOnce');
|
||||
}
|
||||
|
||||
/** 把 "HH:mm[:ss]" 折算成秒数,用于稳定排序。
|
||||
* 直接字典序比较时 "07:30" 会排在 "07:30:00" 之前 —— 它们实际是同一时刻,
|
||||
* 但字符串长度不同就会错位;先归一化到秒数再比更可靠。 */
|
||||
function timeToSeconds(time: string): number {
|
||||
const [h = 0, m = 0, s = 0] = time.split(':').map(Number);
|
||||
return h * 3600 + m * 60 + s;
|
||||
}
|
||||
|
||||
// `null` = 不在编辑;`'new'` = 新建;alarm.id = 编辑该条
|
||||
type EditingState = string | 'new' | null;
|
||||
|
||||
// 内联删除确认的超时时长(ms)。这段时间内用户可以反悔,过了就自动恢复原状。
|
||||
// 4s 是个折中:太短用户来不及点确认,太长又会一直占着"待确认"状态。
|
||||
const DELETE_CONFIRM_TIMEOUT_MS = 4000;
|
||||
|
||||
export function renderAlarmList(root: HTMLElement): () => void {
|
||||
root.classList.add('alarms-panel');
|
||||
root.innerHTML = `
|
||||
<header class="alarms-header">
|
||||
<h2>${t('alarms.title')} <span class="count" id="alarm-count"></span></h2>
|
||||
<button class="btn primary" id="alarm-add">${t('alarms.newBtn')}</button>
|
||||
</header>
|
||||
<section class="alarm-editor-mount" id="alarm-editor-mount" hidden></section>
|
||||
<ul class="alarm-list" id="alarm-list"></ul>
|
||||
`;
|
||||
const listEl = root.querySelector<HTMLUListElement>('#alarm-list')!;
|
||||
const countEl = root.querySelector<HTMLElement>('#alarm-count')!;
|
||||
const addBtn = root.querySelector<HTMLButtonElement>('#alarm-add')!;
|
||||
const editorMount = root.querySelector<HTMLElement>('#alarm-editor-mount')!;
|
||||
let editing: EditingState = null;
|
||||
|
||||
addBtn.onclick = () => {
|
||||
setEditing(editing === 'new' ? null : 'new');
|
||||
};
|
||||
|
||||
function closeEditor(): void {
|
||||
setEditing(null);
|
||||
}
|
||||
|
||||
// 当前真正挂着的编辑态:用于避免 store.alarms 变化触发整面板 draw 时把正在
|
||||
// 录入的表单冲掉。只有 setEditing / closeEditor 主动切到不同目标时才会重建。
|
||||
let mountedEditing: EditingState = null;
|
||||
// 编辑器自身的 cleanup 引用;卸载 / 切换编辑目标时调一下,释放 wheel/input listener + 4s timer。
|
||||
let editorCleanup: (() => void) | null = null;
|
||||
// 单调递增的"挂载代"。每次 setEditing 启动新 mount 自增一次,promise resolve 时
|
||||
// 只有当代号匹配(= 这次挂载仍生效)才把 cleanup 写进 editorCleanup。
|
||||
// 否则旧挂载的 promise 慢一拍 resolve 会把"上一任"cleanup 覆盖在"现任"头上,
|
||||
// 用户关编辑器时只清掉游离 DOM,新挂载的 input listener + 4s revertTimer 全部泄漏。
|
||||
let mountGeneration = 0;
|
||||
|
||||
function setEditing(next: EditingState): void {
|
||||
editing = next;
|
||||
if (next === null) {
|
||||
// 收起编辑器:清掉内容并把 mounted 同步回去,让下次进入相同目标也会重建。
|
||||
editorCleanup?.();
|
||||
editorCleanup = null;
|
||||
editorMount.hidden = true;
|
||||
editorMount.innerHTML = '';
|
||||
mountedEditing = null;
|
||||
} else if (next !== mountedEditing) {
|
||||
// 进入新目标或换目标:先清空旧 DOM 再挂新的,避免旧 input 的 id 冲突。
|
||||
editorCleanup?.();
|
||||
editorCleanup = null;
|
||||
editorMount.hidden = false;
|
||||
editorMount.innerHTML = '';
|
||||
mountedEditing = next;
|
||||
const myGeneration = ++mountGeneration;
|
||||
const cleanupOrUndef = mountAlarmEditor(editorMount, next === 'new' ? null : next, closeEditor);
|
||||
// mountAlarmEditor 现在返回 Promise<() => void>;这里把它当 promise 用,
|
||||
// 把 resolve 出来的 cleanup 存进 editorCleanup(卸载时调用)。
|
||||
// 代号不匹配说明这次挂载已经被"换目标/关闭"覆盖掉了 —— 直接调它的 cleanup
|
||||
// 把游离 DOM 上的 listener / timer 清掉,不写进 editorCleanup,避免后续覆盖现任 cleanup。
|
||||
void cleanupOrUndef.then((fn) => {
|
||||
if (myGeneration !== mountGeneration) { fn(); return; }
|
||||
editorCleanup = fn;
|
||||
});
|
||||
}
|
||||
// next === mountedEditing(非 null)时无需任何动作 —— 编辑器仍在挂载中。
|
||||
// 新建按钮文字始终跟 editing 同步。
|
||||
addBtn.textContent = editing === 'new' ? t('alarms.cancelNew') : t('alarms.newBtn');
|
||||
}
|
||||
|
||||
// === 内联删除确认 ===
|
||||
// 不再弹原生 confirm() —— 改为行内两段式:第一次点"删除" → 行进入"确认态",
|
||||
// 操作区变成"取消 / 确认删除"。这段时间可以反悔(取消 / 4s 超时 / 点别处开始编辑)
|
||||
// store 变化(删除成功)也会自然清掉确认态。
|
||||
let pendingDeleteId: string | null = null;
|
||||
let revertTimer: number | null = null;
|
||||
|
||||
function clearRevertTimer(): void {
|
||||
if (revertTimer !== null) {
|
||||
window.clearTimeout(revertTimer);
|
||||
revertTimer = null;
|
||||
}
|
||||
}
|
||||
function cancelPendingDelete(): void {
|
||||
if (pendingDeleteId === null) return;
|
||||
pendingDeleteId = null;
|
||||
clearRevertTimer();
|
||||
draw();
|
||||
}
|
||||
function requestDelete(id: string): void {
|
||||
// 已经在同一行确认中:再点"删除"算作取消(用户可改主意),避免重复触发 timer。
|
||||
if (pendingDeleteId === id) { cancelPendingDelete(); return; }
|
||||
pendingDeleteId = id;
|
||||
clearRevertTimer();
|
||||
revertTimer = window.setTimeout(() => {
|
||||
revertTimer = null;
|
||||
if (pendingDeleteId !== null) {
|
||||
pendingDeleteId = null;
|
||||
draw();
|
||||
}
|
||||
}, DELETE_CONFIRM_TIMEOUT_MS);
|
||||
draw();
|
||||
}
|
||||
function commitDelete(id: string): void {
|
||||
// 确认按钮:清掉确认态后真正删除;store 变化会触发 draw 重画。
|
||||
pendingDeleteId = null;
|
||||
clearRevertTimer();
|
||||
// 关键:若被删的就是正在编辑的那条,编辑器里 captured 的 existing 仍然引用
|
||||
// 删除前的 alarm 对象 —— 用户点保存会拿 stale id 去 update,无声地把"新建"
|
||||
// 写成"更新不存在 id"或被静默吞掉。先关编辑器,再删。
|
||||
if (mountedEditing === id) setEditing(null);
|
||||
void window.api.alarms.remove(id);
|
||||
}
|
||||
|
||||
// 整个面板生命周期内只建一个 IntersectionObserver,每次 draw() 先 disconnect 再复用。
|
||||
const rowObserver = 'IntersectionObserver' in window
|
||||
? new IntersectionObserver((entries, observer) => {
|
||||
for (const e of entries) {
|
||||
if (e.isIntersecting) {
|
||||
e.target.classList.add('is-in');
|
||||
observer.unobserve(e.target);
|
||||
}
|
||||
}
|
||||
}, { rootMargin: '0px 0px -20px 0px', threshold: 0.01 })
|
||||
: null;
|
||||
|
||||
function draw(): void {
|
||||
// 列表重画:只要 alarms 变化就触发。编辑器部分由 setEditing / closeEditor
|
||||
// 单独管理,不在这里重建(否则 settings 触发的 storageChanged 会把表单清掉)。
|
||||
// 主进程按添加顺序存,UI 上按本地时刻升序更直观 —— 时间早的在上,晚的在下。
|
||||
const items = [...store.alarms.get()].sort(
|
||||
(a, b) => timeToSeconds(a.time) - timeToSeconds(b.time)
|
||||
);
|
||||
const enabled = items.filter(x => x.enabled).length;
|
||||
// 空态时 count header 显示 "0 / 0 启用" 没意义:分分子为零。改为仅显示"0 启用"
|
||||
// 或干脆不渲染。这里采用空态隐藏 count badge,仅在 items.length > 0 时显示。
|
||||
if (items.length === 0) {
|
||||
// 直接用 hidden 属性把整个 span 移出布局,避免 h2 flex 布局里残留 gap 占位
|
||||
countEl.hidden = true;
|
||||
} else {
|
||||
countEl.hidden = false;
|
||||
countEl.textContent = t('alarms.countFmt', String(enabled), String(items.length));
|
||||
}
|
||||
if (items.length === 0) {
|
||||
// 空状态:放一个 48px 的 outline 闹钟图标当视觉锚点
|
||||
listEl.innerHTML = `
|
||||
<li class="alarm-empty">
|
||||
<span class="alarm-empty-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 32 32" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M16 7c-4.5 0-7.8 3.5-7.8 8.2v3.6L6 22.5h20l-2.2-3.7v-3.6c0-4.7-3.3-8.2-7.8-8.2Z" stroke-linejoin="round" />
|
||||
<path d="M12.5 25.5a3.5 3.5 0 0 0 7 0" />
|
||||
<path d="M3 8l2-2M29 8l-2-2" />
|
||||
</svg>
|
||||
</span>
|
||||
<p>${t('alarms.empty')}</p>
|
||||
<p class="alarm-empty-hint">${t('alarms.emptyHint')}</p>
|
||||
</li>`;
|
||||
return;
|
||||
}
|
||||
const noTitle = t('alarms.untitled');
|
||||
const titleAttr = t('alarms.toggle');
|
||||
const ariaEnable = t('alarms.toggleAria');
|
||||
const editLabel = t('alarms.edit');
|
||||
const deleteLabel = t('alarms.delete');
|
||||
const confirmDeleteLabel = t('alarms.confirmDelete');
|
||||
const cancelLabel = t('common.cancel');
|
||||
listEl.innerHTML = items.map((a, i) => {
|
||||
const isConfirming = pendingDeleteId === a.id;
|
||||
// 行始终渲染 4 个按钮(edit / del / cancel-confirm / confirm),CSS 按
|
||||
// data-confirming 切换可见性 —— 不靠 innerHTML 反复改 DOM,省得重新
|
||||
// 绑定 listener 也避免动画跳变。
|
||||
return `
|
||||
<li class="alarm-row ${a.enabled ? '' : 'off'} ${a.repeat === 'daily' ? 'daily' : 'once'}"
|
||||
data-id="${escapeHtml(a.id)}"
|
||||
data-confirming="${isConfirming ? 'true' : 'false'}"
|
||||
style="--alarm-row-i: ${i}">
|
||||
<span class="time">${escapeHtml(a.time)}</span>
|
||||
<div class="label">
|
||||
<b>${escapeHtml(a.label) || `<span style="color:var(--text-faint);font-weight:500;">${noTitle}</span>`}</b>
|
||||
<span>${fmtSubtitle(a)}</span>
|
||||
</div>
|
||||
<span class="actions">
|
||||
<input type="checkbox" class="toggle" data-id="${escapeHtml(a.id)}" ${a.enabled ? 'checked' : ''} title="${titleAttr}" aria-label="${ariaEnable}" />
|
||||
<button class="row-btn edit" data-id="${escapeHtml(a.id)}" title="${editLabel}">${editLabel}</button>
|
||||
<button class="row-btn danger del" data-id="${escapeHtml(a.id)}" title="${deleteLabel}">${deleteLabel}</button>
|
||||
<button class="row-btn cancel-confirm" data-id="${escapeHtml(a.id)}" title="${cancelLabel}">${cancelLabel}</button>
|
||||
<button class="row-btn danger confirm" data-id="${escapeHtml(a.id)}" title="${confirmDeleteLabel}">${confirmDeleteLabel}</button>
|
||||
</span>
|
||||
</li>
|
||||
`;
|
||||
}).join('');
|
||||
// 滚动入场:IntersectionObserver 让只有进入视口的行才"松开"动画
|
||||
// 列表一开始在视口内的行仍会按 --alarm-row-i 错位;之后滚动新行时是单行 fade-up
|
||||
const rows = Array.from(listEl.querySelectorAll<HTMLElement>('.alarm-row'));
|
||||
if (rowObserver) {
|
||||
// 上一轮的行已被 innerHTML 整体替换掉,旧的 observe 目标全是游离节点。
|
||||
// 不 disconnect 的话每次 draw() 都会多留一个仍持有这些节点强引用的
|
||||
// observer,长会话下持续累积(这正是原来的泄漏点)。
|
||||
rowObserver.disconnect();
|
||||
// 拆成两类:首屏内(4 行内)直接 fade-in,避免无可视变化;之后用 IO
|
||||
rows.forEach((row, i) => {
|
||||
if (i < 4) { row.classList.add('is-in'); }
|
||||
else { rowObserver.observe(row); }
|
||||
});
|
||||
} else {
|
||||
// 兜底:降级到全量直接 fade-in
|
||||
rows.forEach(row => row.classList.add('is-in'));
|
||||
}
|
||||
listEl.querySelectorAll<HTMLInputElement>('.toggle').forEach(b => b.onchange = async () => {
|
||||
const id = b.dataset.id!;
|
||||
await window.api.alarms.update(id, { enabled: b.checked });
|
||||
});
|
||||
// 内联编辑:点击"编辑"直接进入内联编辑器。
|
||||
listEl.querySelectorAll<HTMLButtonElement>('.edit').forEach(b => b.onclick = () => {
|
||||
// 进入编辑时取消任何挂起的删除确认 —— 用户意图已转移到编辑。
|
||||
cancelPendingDelete();
|
||||
setEditing(b.dataset.id!);
|
||||
});
|
||||
// 删除:进入行内两段式确认(不再弹原生 confirm)。
|
||||
listEl.querySelectorAll<HTMLButtonElement>('.del').forEach(b => b.onclick = () => {
|
||||
requestDelete(b.dataset.id!);
|
||||
});
|
||||
// 取消 / 确认:直接执行 / 撤销
|
||||
listEl.querySelectorAll<HTMLButtonElement>('.cancel-confirm').forEach(b => b.onclick = () => {
|
||||
cancelPendingDelete();
|
||||
});
|
||||
listEl.querySelectorAll<HTMLButtonElement>('.confirm').forEach(b => b.onclick = () => {
|
||||
commitDelete(b.dataset.id!);
|
||||
});
|
||||
}
|
||||
const unsubAlarms = store.alarms.subscribe(draw);
|
||||
draw();
|
||||
return () => {
|
||||
unsubAlarms();
|
||||
clearRevertTimer();
|
||||
rowObserver?.disconnect();
|
||||
// 编辑器如果还挂着,释放它的 listener + 4s timer。
|
||||
editorCleanup?.();
|
||||
editorCleanup = null;
|
||||
};
|
||||
}
|
||||
239
src/renderer/components/ClockPanel.ts
Normal file
239
src/renderer/components/ClockPanel.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import { store } from '../store';
|
||||
import { pickNextAlarm } from '../../shared/time';
|
||||
import { t } from '../i18n';
|
||||
import { localDateKey, POMODORO_DURATIONS_MS } from '../../shared/constants';
|
||||
|
||||
/** 番茄钟 idle 时卡片上显示的 MM:SS —— 从时长常量派生,别写死 25:00。 */
|
||||
function idlePomoTime(): string {
|
||||
const sec = Math.round(POMODORO_DURATIONS_MS.focus / 1000);
|
||||
return `${String(Math.floor(sec / 60)).padStart(2, '0')}:${String(sec % 60).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 首页:上方一个巨字时钟(hero,居中),下方一行小卡片。
|
||||
* - Hero:HH:MM:SS(实时)+ 日期 + 下一个闹钟的胶囊提示
|
||||
* - 卡片行:番茄钟状态 / 倒计时状态 / 启用的闹钟数
|
||||
*
|
||||
* 巨字时钟是首页的视觉锚点;小卡片只承担辅助信息,整体高度保持紧凑。
|
||||
*/
|
||||
export function renderClockPanel(root: HTMLElement): () => void {
|
||||
root.classList.add('clock-panel');
|
||||
// 首屏 HTML 直接写入本地时间,而不是 "--:--:--" 占位符。
|
||||
// 这样即便 JS 在第一帧 paint 之前还没跑到 tickClock(),用户看到的也是真实时钟,
|
||||
// 而不是一秒以内的占位符闪烁。浮窗时钟(floating-clock/main.ts)走的是同一思路。
|
||||
// store.clockStr 在 store.ts 模块加载时已经由 localHMS() 初始化,
|
||||
// 这里直接 get() 拿到的就是"模块加载时刻"的本地时间,与 IPC 首推相差最多 1s。
|
||||
const initialClock = store.clockStr.get();
|
||||
const initParts = /^\d{2}:\d{2}:\d{2}$/.test(initialClock)
|
||||
? initialClock.split(':')
|
||||
: (() => {
|
||||
const d = new Date();
|
||||
const p = (n: number): string => String(n).padStart(2, '0');
|
||||
return [p(d.getHours()), p(d.getMinutes()), p(d.getSeconds())];
|
||||
})();
|
||||
root.innerHTML = `
|
||||
<section class="clock-hero" aria-label="${t('clock.subtitle')}">
|
||||
<div class="clock-display mono" id="home-time"><span class="hh">${initParts[0]}</span><span class="colon">:</span><span class="mm">${initParts[1]}</span><span class="colon">:</span><span class="sec">${initParts[2]}</span></div>
|
||||
<div class="clock-hero-meta">
|
||||
<span class="clock-date" id="home-date">—</span>
|
||||
<span class="clock-meta-sep" aria-hidden="true">·</span>
|
||||
<span class="clock-next" id="home-next">${t('clock.nextNone')}</span>
|
||||
</div>
|
||||
</section>
|
||||
<section class="home-grid" role="list" aria-label="状态卡片">
|
||||
<div class="home-card" role="listitem" data-card="pomo">
|
||||
<div class="home-card-label">${t('nav.pomodoro')}</div>
|
||||
<div class="home-card-value" id="home-pomo-state">${t('clock.pomoStateIdle')}</div>
|
||||
<div class="home-card-sub mono" id="home-pomo-time">--:--</div>
|
||||
</div>
|
||||
<div class="home-card" role="listitem" data-card="countdown">
|
||||
<div class="home-card-label">${t('nav.countdown')}</div>
|
||||
<div class="home-card-value" id="home-cd-state">${t('countdown.stateIdle')}</div>
|
||||
<div class="home-card-sub mono" id="home-cd-time">--:--</div>
|
||||
</div>
|
||||
<div class="home-card" role="listitem" data-card="alarms">
|
||||
<div class="home-card-label">${t('nav.alarms')}</div>
|
||||
<div class="home-card-value mono" id="home-enabled">0</div>
|
||||
<div class="home-card-sub">${t('clock.statEnabledSub')}</div>
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
|
||||
const timeEl = root.querySelector<HTMLElement>('#home-time')!;
|
||||
// 缓存 hh/mm/sec 子节点引用 —— 每秒只改 textContent,不再重建 innerHTML:
|
||||
// 每秒重建一次 innerHTML 既贵又会让任何依赖节点身份的 CSS 动画/变换从头重放。
|
||||
const hhEl = timeEl.querySelector<HTMLElement>('.hh')!;
|
||||
const mmEl = timeEl.querySelector<HTMLElement>('.mm')!;
|
||||
const ssEl = timeEl.querySelector<HTMLElement>('.sec')!;
|
||||
const dateEl = root.querySelector<HTMLElement>('#home-date')!;
|
||||
const nextEl = root.querySelector<HTMLElement>('#home-next')!;
|
||||
const pomoCard = root.querySelector<HTMLElement>('[data-card="pomo"]')!;
|
||||
const pomoStateEl = root.querySelector<HTMLElement>('#home-pomo-state')!;
|
||||
const pomoTimeEl = root.querySelector<HTMLElement>('#home-pomo-time')!;
|
||||
const enabledEl = root.querySelector<HTMLElement>('#home-enabled')!;
|
||||
const cdCard = root.querySelector<HTMLElement>('[data-card="countdown"]')!;
|
||||
const cdStateEl = root.querySelector<HTMLElement>('#home-cd-state')!;
|
||||
const cdTimeEl = root.querySelector<HTMLElement>('#home-cd-time')!;
|
||||
|
||||
const weekdayChars = ['日', '一', '二', '三', '四', '五', '六'];
|
||||
|
||||
// 把本地 Date 序列化为 HH:MM:SS。本面板用它做两件事:
|
||||
// 1) 首屏 mount 时立刻填一次,避免 0~1s 内"--:--:--"占位符闪烁;
|
||||
// 2) IPC clockStr 拿到空串 / 格式异常时兜底,不让 hh/mm/sec 被写成 '' 导致"::"显示。
|
||||
// 与 shared/time.formatLocalHMS 行为一致,但放在组件内避免再绕一道跨模块依赖。
|
||||
function localHMS(d: Date = new Date()): string {
|
||||
const p = (n: number): string => String(n).padStart(2, '0');
|
||||
return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
||||
}
|
||||
|
||||
// 校验 "HH:MM:SS" 形状:必须恰好两段冒号、每段两位数字。空串、'::'、'NaN:NaN'、'12:34' 都判负。
|
||||
// 早先用 parts[N] ?? '00' 兜底,空串 '' 并不触发 ??,于是 hhEl 会被写成 '' → 视觉上变成 "::"。
|
||||
const HHMMSS_RE = /^\d{2}:\d{2}:\d{2}$/;
|
||||
function isValidHHMMSS(s: string): boolean {
|
||||
return typeof s === 'string' && HHMMSS_RE.test(s);
|
||||
}
|
||||
|
||||
function tickClock(): void {
|
||||
// 优先级:有效 IPC clockStr > 本地时间。store 初始值(localHMS)可能因模块加载时刻
|
||||
// 与 IPC 首推之间错位而比当前慢几秒,但仍然是合法 HH:MM:SS,这里就用它,不重新 new Date()。
|
||||
const raw = store.clockStr.get();
|
||||
const t = isValidHHMMSS(raw) ? raw : localHMS();
|
||||
const parts = t.split(':');
|
||||
const hh = parts[0]!;
|
||||
const mm = parts[1]!;
|
||||
const ss = parts[2]!;
|
||||
if (hhEl.textContent !== hh) hhEl.textContent = hh;
|
||||
if (mmEl.textContent !== mm) mmEl.textContent = mm;
|
||||
if (ssEl.textContent !== ss) ssEl.textContent = ss;
|
||||
const now = new Date();
|
||||
const w = weekdayChars[now.getDay()] ?? '';
|
||||
const dateText = `星期${w} ${now.getMonth() + 1}月${now.getDate()}日`;
|
||||
if (dateEl.textContent !== dateText) dateEl.textContent = dateText;
|
||||
}
|
||||
|
||||
function syncNext(): void {
|
||||
// 用 pickNextAlarm 拿真实"下一个要响的" —— 多个 enabled 闹钟时显示最近的,
|
||||
// 避免早先 enabledAlarms[0] 把最远的那条当成"下一个"。
|
||||
const enabledAlarms = store.alarms.get().filter(x => x.enabled);
|
||||
const { alarm, nextAt } = pickNextAlarm(enabledAlarms, new Date());
|
||||
if (!alarm) {
|
||||
nextEl.textContent = t('clock.nextNone');
|
||||
nextEl.classList.remove('has-alarm');
|
||||
} else {
|
||||
const diffMin = Math.max(1, Math.round((nextAt - Date.now()) / 60000));
|
||||
// 不要 escapeHtml:下面是 textContent 赋值,本身就不解释 HTML。
|
||||
// 先转义再写 textContent 会让标签里的 & 显示成 &。
|
||||
const label = alarm.label || t('floating.alarms.tagDefault');
|
||||
nextEl.classList.add('has-alarm');
|
||||
nextEl.textContent = diffMin < 60
|
||||
? t('clock.nextFmtMin', alarm.time, diffMin, label)
|
||||
: t('clock.nextFmtHm', alarm.time, Math.floor(diffMin / 60), diffMin % 60, label);
|
||||
}
|
||||
enabledEl.textContent = String(enabledAlarms.length);
|
||||
}
|
||||
|
||||
function syncPomo(): void {
|
||||
const { active } = store.pomodoro.get();
|
||||
if (!active) {
|
||||
pomoStateEl.textContent = t('clock.pomoStateIdle');
|
||||
pomoTimeEl.textContent = idlePomoTime();
|
||||
pomoCard.dataset.state = 'idle';
|
||||
return;
|
||||
}
|
||||
const remain = active.paused
|
||||
? active.remainingMs
|
||||
: Math.max(0, active.endsAt - Date.now());
|
||||
const sec = Math.ceil(remain / 1000);
|
||||
const mm = String(Math.floor(sec / 60)).padStart(2, '0');
|
||||
const ss = String(sec % 60).padStart(2, '0');
|
||||
pomoTimeEl.textContent = `${mm}:${ss}`;
|
||||
const phaseLabel = ({ focus: t('pomo.focus'), shortBreak: t('pomo.shortBreak'), longBreak: t('pomo.longBreak') } as Record<string, string>)[active.phase] ?? '';
|
||||
pomoStateEl.textContent = active.paused
|
||||
? t('pomo.paused')
|
||||
: phaseLabel;
|
||||
if (active.paused) pomoCard.dataset.state = 'paused';
|
||||
else if (active.phase !== 'focus') pomoCard.dataset.state = 'break';
|
||||
else pomoCard.dataset.state = 'running';
|
||||
}
|
||||
|
||||
function syncCountdown(): void {
|
||||
const { active } = store.countdown.get();
|
||||
if (!active) {
|
||||
cdStateEl.textContent = t('countdown.stateIdle');
|
||||
cdTimeEl.textContent = '--:--';
|
||||
cdCard.dataset.state = 'idle';
|
||||
return;
|
||||
}
|
||||
const remain = active.paused
|
||||
? active.remainingMs
|
||||
: Math.max(0, active.endsAt - Date.now());
|
||||
const totalSec = Math.ceil(remain / 1000);
|
||||
const h = Math.floor(totalSec / 3600);
|
||||
const m = Math.floor((totalSec % 3600) / 60);
|
||||
const s = totalSec % 60;
|
||||
cdTimeEl.textContent = h > 0
|
||||
? `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
|
||||
: `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
||||
if (active.paused) {
|
||||
cdStateEl.textContent = t('countdown.statePaused');
|
||||
cdCard.dataset.state = 'paused';
|
||||
} else {
|
||||
cdStateEl.textContent = t('countdown.stateRunning');
|
||||
cdCard.dataset.state = 'running';
|
||||
}
|
||||
}
|
||||
|
||||
// 首屏立刻画一次:挂载前 store.clockStr 已经在模块加载时由 localHMS() 初始化,
|
||||
// 跟着 IPC clockTick 在 0~1s 内覆盖。若不主动画,DOM 会停留在 "--:--:--" 占位符
|
||||
// 直到下一个 IPC tick(最多 1s 的空窗),用户能看到闪现。Float-clock 已经做了同样处理。
|
||||
tickClock();
|
||||
|
||||
// clockStr 由主进程每秒推一次,本面板所有随时间漂移的读数都挂在这一个驱动上,
|
||||
// 不再另开 1Hz 的 driftTimer(两个同频定时器做同类事,白白多一次唤醒)。
|
||||
// 但 idle 时(无 enabled 闹钟 + pomo/countdown 都没在跑)每秒重画是空转:
|
||||
// tickClock 已经在比 textContent,没变就跳过;其它三个同步函数也按需短路,
|
||||
// 让首页在 idle 状态下每秒只做 4 个 textContent 比较,不走 pickNextAlarm / Date 构造。
|
||||
let lastPomoActive = store.pomodoro.get().active;
|
||||
let lastCountdownActive = store.countdown.get().active;
|
||||
let lastEnabledCount = store.alarms.get().filter(a => a.enabled).length;
|
||||
const unsubClock = store.clockStr.subscribe(() => {
|
||||
tickClock();
|
||||
// 仅在有内容时才走昂贵的同步路径
|
||||
const enabledCount = lastEnabledCount;
|
||||
if (enabledCount > 0) syncNext();
|
||||
if (lastPomoActive) syncPomo();
|
||||
if (lastCountdownActive) syncCountdown();
|
||||
});
|
||||
// 状态变化后刷新"是否要同步"标志位:减少 1Hz tick 时的判断开销
|
||||
const unsubAlarms = store.alarms.subscribe(() => {
|
||||
lastEnabledCount = store.alarms.get().filter(a => a.enabled).length;
|
||||
syncNext();
|
||||
});
|
||||
const unsubPomo = store.pomodoro.subscribe((p) => {
|
||||
lastPomoActive = p.active;
|
||||
syncPomo();
|
||||
});
|
||||
const unsubCountdown = store.countdown.subscribe((c) => {
|
||||
lastCountdownActive = c.active;
|
||||
syncCountdown();
|
||||
});
|
||||
syncNext();
|
||||
syncPomo();
|
||||
syncCountdown();
|
||||
|
||||
// 跨天后日期会变
|
||||
const dayTimer = window.setInterval(() => {
|
||||
const today = localDateKey();
|
||||
if (dateEl.dataset.day !== today) {
|
||||
dateEl.dataset.day = today;
|
||||
tickClock();
|
||||
}
|
||||
}, 60_000);
|
||||
return () => {
|
||||
unsubClock();
|
||||
unsubAlarms();
|
||||
unsubPomo();
|
||||
unsubCountdown();
|
||||
window.clearInterval(dayTimer);
|
||||
};
|
||||
}
|
||||
325
src/renderer/components/CountdownPanel.ts
Normal file
325
src/renderer/components/CountdownPanel.ts
Normal file
@@ -0,0 +1,325 @@
|
||||
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();
|
||||
};
|
||||
}
|
||||
33
src/renderer/components/PinButton.ts
Normal file
33
src/renderer/components/PinButton.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { store } from '../store';
|
||||
import { t } from '../i18n';
|
||||
|
||||
// 一把 14px 风格的"图钉/别针":头是圆弧 + 斜线,底部一根短针。
|
||||
// 与 Topbar 统一:16 viewBox · stroke 1.4 · round caps · currentColor。
|
||||
const ICON_PIN = `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M9.5 2.5h-3l-1 3.5L4 7.5v1.5h8V7.5l-1.5-1.5-1-3.5Z" />
|
||||
<path d="M8 9v4.5" />
|
||||
</svg>`;
|
||||
|
||||
export function renderPinButton(root: HTMLElement): () => void {
|
||||
root.classList.add('pin-btn');
|
||||
root.setAttribute('aria-label', t('topbar.pinOff'));
|
||||
// 纯图标按钮:靠图标本身 + 旋转来表达 on/off 状态
|
||||
root.innerHTML = `
|
||||
<span class="pin-btn-mark" aria-hidden="true">${ICON_PIN}</span>
|
||||
`;
|
||||
function sync(): void {
|
||||
const on = store.topbar.get().alwaysOnTop;
|
||||
root.classList.toggle('on', on);
|
||||
root.setAttribute('aria-pressed', String(on));
|
||||
const key = on ? 'topbar.pinOn' : 'topbar.pinOff';
|
||||
root.setAttribute('aria-label', t(key as Parameters<typeof t>[0]));
|
||||
}
|
||||
const unsubTopbar = store.topbar.subscribe(sync);
|
||||
const onClick = () => { void window.api.windows.toggleAlwaysOnTop(); };
|
||||
root.addEventListener('click', onClick);
|
||||
sync();
|
||||
return () => {
|
||||
unsubTopbar();
|
||||
root.removeEventListener('click', onClick);
|
||||
};
|
||||
}
|
||||
178
src/renderer/components/PomodoroPanel.ts
Normal file
178
src/renderer/components/PomodoroPanel.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { store } from '../store';
|
||||
import { createSecondTicker, nextCeilBoundary } from '../secondTick';
|
||||
import { formatHMS } from '../../shared/time';
|
||||
import { POMODORO_DURATIONS_MS, localDateKey } from '../../shared/constants';
|
||||
import { t } from '../i18n';
|
||||
import type { PomodoroPhase } from '../../shared/types';
|
||||
|
||||
function phaseLabel(p: PomodoroPhase): string {
|
||||
return ({ focus: t('pomo.focus'), shortBreak: t('pomo.shortBreak'), longBreak: t('pomo.longBreak') } as Record<PomodoroPhase, string>)[p];
|
||||
}
|
||||
|
||||
export function renderPomodoroPanel(root: HTMLElement): () => void {
|
||||
root.innerHTML = `
|
||||
<div class="pomo-panel">
|
||||
<div class="pomo-head">
|
||||
<span class="pomo-phase" id="pomo-phase">${t('pomo.idle')}</span>
|
||||
<span class="pomo-round-pill" id="pomo-round-pill"></span>
|
||||
</div>
|
||||
<div class="pomo-display" role="timer" aria-label="${t('pomo.ariaDisplay')}" id="pomo-timer">
|
||||
<svg width="180" height="180" viewBox="0 0 180 180" aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient id="pomo-grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="var(--accent)" />
|
||||
<stop offset="100%" stop-color="var(--accent-2)" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle class="ring-bg" cx="90" cy="90" r="80" />
|
||||
<circle id="ring-fg" class="ring-fg" cx="90" cy="90" r="80"
|
||||
pathLength="100" transform="rotate(-90 90 90)" />
|
||||
</svg>
|
||||
<div class="pomo-center">
|
||||
<div class="pomo-time" id="pomo-time"><span id="pomo-mm">--</span><span class="colon">:</span><span id="pomo-ss">--</span></div>
|
||||
<div class="pomo-sub" id="pomo-sub">25 min ${t('pomo.focus')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pomo-actions" id="pomo-actions" data-state="idle">
|
||||
<button class="btn primary lg" id="pomo-start">${t('pomo.start')}</button>
|
||||
<button class="btn lg" id="pomo-pause" style="display:none">${t('pomo.pause')}</button>
|
||||
<button class="btn lg" id="pomo-resume" style="display:none">${t('pomo.resume')}</button>
|
||||
<button class="btn ghost lg" id="pomo-skip">${t('pomo.skip')}</button>
|
||||
<button class="btn ghost lg" id="pomo-reset" style="display:none">${t('pomo.reset')}</button>
|
||||
</div>
|
||||
<div class="pomo-foot">
|
||||
<span class="pomo-today" id="pomo-today">${t('pomo.today')} <strong id="pomo-today-count">0</strong> ${t('pomo.todayUnit')}</span>
|
||||
<span class="pomo-foot-sep">·</span>
|
||||
<span>${t('pomo.footHint')}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
const sub = root.querySelector<HTMLElement>('#pomo-sub')!;
|
||||
const timeMm = root.querySelector<HTMLElement>('#pomo-mm')!;
|
||||
const timeSs = root.querySelector<HTMLElement>('#pomo-ss')!;
|
||||
const ring = root.querySelector<SVGCircleElement>('#ring-fg')!;
|
||||
const timerEl = root.querySelector<HTMLElement>('#pomo-timer')!;
|
||||
const phase = root.querySelector<HTMLElement>('#pomo-phase')!;
|
||||
const roundPill = root.querySelector<HTMLElement>('#pomo-round-pill')!;
|
||||
const todayCount = root.querySelector<HTMLElement>('#pomo-today-count')!;
|
||||
const actions = root.querySelector<HTMLElement>('#pomo-actions')!;
|
||||
const btnStart = root.querySelector<HTMLButtonElement>('#pomo-start')!;
|
||||
const btnPause = root.querySelector<HTMLButtonElement>('#pomo-pause')!;
|
||||
const btnResume = root.querySelector<HTMLButtonElement>('#pomo-resume')!;
|
||||
const btnSkip = root.querySelector<HTMLButtonElement>('#pomo-skip')!;
|
||||
const btnReset = root.querySelector<HTMLButtonElement>('#pomo-reset')!;
|
||||
|
||||
btnStart.onclick = () => void window.api.pomodoro.start();
|
||||
btnPause.onclick = () => void window.api.pomodoro.pause();
|
||||
btnResume.onclick = () => void window.api.pomodoro.resume();
|
||||
btnSkip.onclick = () => void window.api.pomodoro.skip();
|
||||
btnReset.onclick = () => void window.api.pomodoro.reset();
|
||||
|
||||
function setVisible(el: HTMLElement, visible: boolean): void {
|
||||
// 初始隐藏必须写成内联 style="display:none",不能用 [hidden] 属性:
|
||||
// global.css 的 `.btn[hidden] { display: none }` 特异性高于内联清空后的回退,
|
||||
// 一旦带上 hidden 属性,这里的 display='' 就再也显不出来(F-14 修复)。
|
||||
el.style.display = visible ? '' : 'none';
|
||||
}
|
||||
|
||||
// 每秒只改文本,不再重建节点 —— 原来这里是 time.innerHTML = `...`,
|
||||
// 每秒把两个冒号和秒数节点全部销毁重建(做法参照 ClockPanel)。
|
||||
// 番茄钟最长 25 分钟,HH 永远是 0;显示 MM:SS 即可。
|
||||
// secSmall 保留原有差异:idle 的秒是正常字号,running 的秒带 .sec(20px + 暗色)。
|
||||
function paintTime(mm: string, ss: string, secSmall: boolean): void {
|
||||
if (timeMm.textContent !== mm) timeMm.textContent = mm;
|
||||
if (timeSs.textContent !== ss) timeSs.textContent = ss;
|
||||
const cls = secSmall ? 'sec' : '';
|
||||
if (timeSs.className !== cls) timeSs.className = cls;
|
||||
}
|
||||
|
||||
function draw(): void {
|
||||
const { active, history } = store.pomodoro.get();
|
||||
// 今日计数:跨天后 store 还没更新,但 localDateKey 已变,强制重置显示
|
||||
const today = localDateKey();
|
||||
const displayCount = history.date === today ? history.count : 0;
|
||||
todayCount.textContent = String(displayCount);
|
||||
|
||||
if (!active) {
|
||||
// 未开始
|
||||
// 从时长常量派生,别写死 25 —— 否则改了 POMODORO_DURATIONS_MS
|
||||
// 这里和下面那行 sub 就会互相打架。
|
||||
// 番茄钟最长 25 分钟,HH 永远 0 → 只显示 MM:SS。
|
||||
{
|
||||
const sec = Math.round(POMODORO_DURATIONS_MS.focus / 1000);
|
||||
const p = (n: number): string => String(n).padStart(2, '0');
|
||||
paintTime(p(Math.floor(sec / 60)), p(sec % 60), false);
|
||||
}
|
||||
sub.textContent = `${Math.round(POMODORO_DURATIONS_MS.focus / 60000)} min ${t('pomo.focus')}`;
|
||||
phase.textContent = t('pomo.idle');
|
||||
ring.setAttribute('stroke-dasharray', '0 100');
|
||||
ring.setAttribute('stroke', 'url(#pomo-grad)');
|
||||
ring.classList.add('idle');
|
||||
ring.dataset.phase = 'focus';
|
||||
timerEl.style.setProperty('--ring-pulse', '0'); // idle 不呼吸
|
||||
timerEl.dataset.pulse = '0';
|
||||
actions.dataset.state = 'idle';
|
||||
setVisible(btnStart, true);
|
||||
setVisible(btnPause, false);
|
||||
setVisible(btnResume, false);
|
||||
setVisible(btnReset, false);
|
||||
btnSkip.disabled = true;
|
||||
roundPill.textContent = t('pomo.roundPill', 1);
|
||||
timerEl.setAttribute('aria-label', t('pomo.ariaIdle', t('pomo.focus'), Math.round(POMODORO_DURATIONS_MS.focus / 60000)));
|
||||
return;
|
||||
}
|
||||
const remain = active.paused ? active.remainingMs : Math.max(0, active.endsAt - Date.now());
|
||||
const totalSec = Math.ceil(remain / 1000);
|
||||
const formatted = formatHMS(totalSec);
|
||||
const parts = formatted.split(':');
|
||||
const mm = parts[1] ?? '00';
|
||||
const ss = parts[2] ?? '00';
|
||||
paintTime(mm, ss, true);
|
||||
const isBreak = active.phase !== 'focus';
|
||||
const total = POMODORO_DURATIONS_MS[active.phase];
|
||||
const ratio = Math.max(0, Math.min(1, remain / total));
|
||||
ring.setAttribute('stroke-dasharray', `${ratio * 100} 100`);
|
||||
ring.setAttribute('stroke', isBreak ? 'var(--ok)' : 'url(#pomo-grad)');
|
||||
ring.classList.remove('idle');
|
||||
ring.dataset.phase = isBreak ? 'break' : 'focus';
|
||||
// 暂停时不呼吸;运行中呼吸
|
||||
timerEl.style.setProperty('--ring-pulse', active.paused ? '0' : '1');
|
||||
timerEl.dataset.pulse = active.paused ? '0' : '1';
|
||||
sub.textContent = active.paused ? t('pomo.paused') : isBreak ? t('pomo.subBreak') : t('pomo.subFocus');
|
||||
phase.textContent = phaseLabel(active.phase) + (active.paused ? ' · ' + t('pomo.paused') : '');
|
||||
actions.dataset.state = active.paused ? 'paused' : 'running';
|
||||
setVisible(btnStart, false);
|
||||
setVisible(btnPause, !active.paused);
|
||||
setVisible(btnResume, active.paused);
|
||||
setVisible(btnReset, true);
|
||||
btnSkip.disabled = false;
|
||||
// 用 currentRound:用户心智模型是"正在进行第 N 轮"。
|
||||
// active.currentRound 在 transition 进 focus 时 +1,所以从 1 开始累计。
|
||||
roundPill.textContent = t('pomo.roundPill', active.currentRound);
|
||||
const pausedSuffix = active.paused ? t('pomo.ariaPaused') : '';
|
||||
timerEl.setAttribute('aria-label',
|
||||
t('pomo.ariaRemain', phaseLabel(active.phase), pausedSuffix, formatted));
|
||||
}
|
||||
|
||||
// 跨午夜时 history.count 不会自动清零(要等 storage 写盘后下次启动),
|
||||
// 渲染端每分钟检查一次:发现跨天则把 display 视为 0
|
||||
const dayTimer = window.setInterval(draw, 60_000);
|
||||
|
||||
// 显示精度是秒,所以睡到「读数下一次改变」即可(改造前是 rAF 60Hz + 240ms 闸门)。
|
||||
// 暂停 / 未开始时读数不会自己变,nextAt 返回 null,完全不装定时器。
|
||||
const ticker = createSecondTicker(draw, () => {
|
||||
const { active } = store.pomodoro.get();
|
||||
return !active || active.paused ? null : nextCeilBoundary(active.endsAt);
|
||||
});
|
||||
|
||||
// 状态变化(开始 / 暂停 / 继续 / 跳过 / 重置)后要重排下一次唤醒。
|
||||
const unsubPomodoro = store.pomodoro.subscribe(() => { draw(); ticker.restart(); });
|
||||
|
||||
draw();
|
||||
|
||||
return () => {
|
||||
ticker.stop();
|
||||
window.clearInterval(dayTimer);
|
||||
unsubPomodoro();
|
||||
};
|
||||
}
|
||||
222
src/renderer/components/SettingsPanel.ts
Normal file
222
src/renderer/components/SettingsPanel.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
// src/renderer/components/SettingsPanel.ts
|
||||
// 设置面板:皮肤 + 窗口置顶 + 关于。
|
||||
// 2026-08 精简后,提醒方式只剩"系统通知"一种(写死在 Notifier 里),
|
||||
// 不再暴露给用户配置;铃声、音量、试听按钮也一并下线。
|
||||
import { store } from '../store';
|
||||
import { setSkin } from '../themeApply';
|
||||
import { SKINS } from '../../shared/theme';
|
||||
import { t } from '../i18n';
|
||||
import { toast } from './Toast';
|
||||
|
||||
/** 颜色字符串收紧校验:只接受 #RGB / #RGBA / #RRGGBB / #RRGGBBAA。
|
||||
* 来源是 share/theme.ts(SKINS),目前受信任;但拦截掉"red; ..." 类 CSS 注入面,
|
||||
* 万一未来把 SKINS 数据迁到 JSON 就立刻生效。 */
|
||||
const COLOR_RE = /^#[0-9a-fA-F]{3,8}$/;
|
||||
|
||||
const ICON_CHECK_SVG = `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3.5 8.5l3 3 6-6.5" /></svg>`;
|
||||
|
||||
export function renderSettingsPanel(root: HTMLElement): () => void {
|
||||
root.classList.add('settings-panel');
|
||||
root.innerHTML = `
|
||||
<header class="settings-header">
|
||||
<h2>${t('settings.title')}</h2>
|
||||
<p class="settings-sub">${t('settings.sub')}</p>
|
||||
</header>
|
||||
|
||||
<section class="settings-section" aria-labelledby="settings-skin-h">
|
||||
<h3 id="settings-skin-h" class="settings-section-h">
|
||||
<span>${t('settings.skinTitle')}</span>
|
||||
<span class="settings-current" id="settings-skin-current"></span>
|
||||
</h3>
|
||||
<p class="settings-section-desc">${t('settings.skinDesc')}</p>
|
||||
<div class="skin-grid" id="skin-grid" role="radiogroup" aria-labelledby="settings-skin-h"></div>
|
||||
</section>
|
||||
|
||||
<section class="settings-section" aria-labelledby="settings-window-h">
|
||||
<h3 id="settings-window-h" class="settings-section-h">${t('settings.windowTitle')}</h3>
|
||||
<label class="settings-row">
|
||||
<span class="settings-row-label">
|
||||
<span class="settings-row-name">${t('settings.alwaysOnTop')}</span>
|
||||
<span class="settings-row-desc">${t('settings.alwaysOnTopDesc')}</span>
|
||||
</span>
|
||||
<span class="settings-row-control">
|
||||
<input type="checkbox" id="settings-aot" />
|
||||
</span>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="settings-section" aria-labelledby="settings-about-h">
|
||||
<h3 id="settings-about-h" class="settings-section-h">${t('settings.aboutTitle')}</h3>
|
||||
<p class="settings-about-line">
|
||||
<span class="settings-about-label">${t('settings.aboutDeveloper')}</span>
|
||||
<a class="settings-about-link"
|
||||
id="settings-about-link"
|
||||
href="https://www.guanjihuan.com/about"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="${t('settings.aboutDeveloperAria')}">https://www.guanjihuan.com/about</a>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<footer class="settings-foot">
|
||||
<span>${t('settings.foot')}</span>
|
||||
</footer>
|
||||
`;
|
||||
|
||||
const grid = root.querySelector<HTMLElement>('#skin-grid')!;
|
||||
const currentEl = root.querySelector<HTMLElement>('#settings-skin-current')!;
|
||||
const aotInput = root.querySelector<HTMLInputElement>('#settings-aot')!;
|
||||
// 缓存:上一次画过的 active skin id。store.skin 在 setSkin 之后会触发 2 次
|
||||
// subscribe(本地 setSkin + bootstrap storageChanged 转发),每次都 paintSkins()
|
||||
// 会把整张 grid 重写 + 重新绑 listener。用 id 短路掉重复画。
|
||||
let paintedSkin: string | null = null;
|
||||
|
||||
/** 一个 skin-card DOM;数据走 textContent / setAttribute,杜绝 innerHTML 注入。 */
|
||||
function buildSkinCard(s: typeof SKINS[number], active: string): HTMLElement {
|
||||
const isActive = s.id === active;
|
||||
const [a, b, c, d] = s.preview;
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'skin-card' + (isActive ? ' active' : '');
|
||||
btn.type = 'button';
|
||||
btn.setAttribute('role', 'radio');
|
||||
btn.setAttribute('aria-checked', String(isActive));
|
||||
btn.dataset.skin = s.id;
|
||||
|
||||
const preview = document.createElement('span');
|
||||
preview.className = 'skin-card-preview';
|
||||
preview.setAttribute('aria-hidden', 'true');
|
||||
preview.style.display = 'flex';
|
||||
preview.style.flexDirection = 'column';
|
||||
preview.style.width = '100%';
|
||||
preview.style.height = '56px';
|
||||
preview.style.borderRadius = '4px';
|
||||
preview.style.overflow = 'hidden';
|
||||
for (const color of [a, b, c, d]) {
|
||||
// 颜色做正则校验,不合规就 fallback 到 transparent —— 比抛错更宽容。
|
||||
const stripe = document.createElement('span');
|
||||
stripe.className = 'skin-card-stripe';
|
||||
stripe.style.background = COLOR_RE.test(color) ? color : 'transparent';
|
||||
stripe.style.flex = '1 1 0';
|
||||
stripe.style.width = '100%';
|
||||
stripe.style.minHeight = '14px';
|
||||
preview.appendChild(stripe);
|
||||
}
|
||||
btn.appendChild(preview);
|
||||
|
||||
const meta = document.createElement('span');
|
||||
meta.className = 'skin-card-meta';
|
||||
|
||||
const name = document.createElement('span');
|
||||
name.className = 'skin-card-name';
|
||||
name.textContent = s.name; // 不再走 innerHTML
|
||||
meta.appendChild(name);
|
||||
|
||||
const blurb = document.createElement('span');
|
||||
blurb.className = 'skin-card-blurb';
|
||||
blurb.textContent = s.blurb; // 不再走 innerHTML
|
||||
meta.appendChild(blurb);
|
||||
|
||||
const mode = document.createElement('span');
|
||||
mode.className = 'skin-card-mode';
|
||||
mode.dataset.mode = s.mode;
|
||||
mode.textContent = s.mode === 'dark' ? t('settings.modeDark') : t('settings.modeLight');
|
||||
meta.appendChild(mode);
|
||||
btn.appendChild(meta);
|
||||
|
||||
const check = document.createElement('span');
|
||||
check.className = 'skin-card-check';
|
||||
check.setAttribute('aria-hidden', 'true');
|
||||
// 复选图标是受信任的静态 SVG,直接 innerHTML 设置——非用户输入路径。
|
||||
check.innerHTML = ICON_CHECK_SVG;
|
||||
btn.appendChild(check);
|
||||
|
||||
return btn;
|
||||
}
|
||||
|
||||
function paintSkins(): void {
|
||||
const active = store.skin.get();
|
||||
if (active === paintedSkin) return;
|
||||
paintedSkin = active;
|
||||
const frag = document.createDocumentFragment();
|
||||
for (const s of SKINS) frag.appendChild(buildSkinCard(s, active));
|
||||
grid.replaceChildren(frag);
|
||||
}
|
||||
|
||||
function syncCurrent(): void {
|
||||
const id = store.skin.get();
|
||||
const meta = SKINS.find(s => s.id === id);
|
||||
currentEl.textContent = meta ? `· ${meta.name}` : '';
|
||||
}
|
||||
|
||||
// 三组事件 handler 单独命名(不要用匿名箭头直接 addEventListener):
|
||||
// cleanup 要 removeEventListener 时必须拿得到同一个函数引用;匿名箭头即使
|
||||
// 长得一样也无法被 removeEventListener 匹配。这里把 handler 提到外层。
|
||||
function onGridClick(e: MouseEvent): void {
|
||||
const card = (e.target as HTMLElement).closest<HTMLElement>('.skin-card');
|
||||
if (!card) return;
|
||||
const id = card.dataset.skin;
|
||||
if (!id) return;
|
||||
setSkin(id);
|
||||
}
|
||||
function onGridKeydown(e: KeyboardEvent): void {
|
||||
if (e.key !== 'Enter' && e.key !== ' ') return;
|
||||
const card = (e.target as HTMLElement).closest<HTMLElement>('.skin-card');
|
||||
if (!card) return;
|
||||
e.preventDefault();
|
||||
const id = card.dataset.skin;
|
||||
if (id) setSkin(id);
|
||||
}
|
||||
function onAotChange(): void {
|
||||
// 切换框先翻转"未来态",再触发 IPC —— IPC 成功 → topbarChanged → store 同步,
|
||||
// 失败时本地复选框状态会暂时与 store 不一致,下一次 subscribe 自动修正。
|
||||
void window.api.windows.toggleAlwaysOnTop();
|
||||
}
|
||||
/** 外链点击:走 IPC 让主进程用系统默认浏览器打开 —— 而不是开 Electron 自己的
|
||||
* BrowserWindow(target=_blank 在 Electron 里仍会落在本应用里)。
|
||||
* 中键 / Ctrl+Click 仍交给浏览器原生处理(不 preventDefault),尊重用户偏好。 */
|
||||
function onAboutClick(e: MouseEvent): void {
|
||||
if (e.defaultPrevented) return;
|
||||
if (e.button !== 0) return; // 中键 / 右键:保留原生行为
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
||||
e.preventDefault();
|
||||
const a = e.currentTarget as HTMLAnchorElement;
|
||||
const href = a.href;
|
||||
void window.api.shell.openExternal(href).then((ok) => {
|
||||
if (!ok) toast(t('settings.aboutOpenFailed'), 'warn');
|
||||
});
|
||||
}
|
||||
/** Enter 键:链接本身就响应 Enter,无需重复实现;但按下时浏览器会先触发 click,
|
||||
* 上面 onAboutClick 已拦截;这里只需保证不被其它键盘 handler 抢走焦点。 */
|
||||
|
||||
grid.addEventListener('click', onGridClick);
|
||||
|
||||
// 卡片用 radio role,键盘可达:focus + Enter/Space 应用
|
||||
grid.addEventListener('keydown', onGridKeydown);
|
||||
|
||||
function syncAot(): void {
|
||||
aotInput.checked = store.topbar.get().alwaysOnTop === true;
|
||||
}
|
||||
aotInput.addEventListener('change', onAotChange);
|
||||
|
||||
const aboutLink = root.querySelector<HTMLAnchorElement>('#settings-about-link')!;
|
||||
aboutLink.addEventListener('click', onAboutClick);
|
||||
|
||||
const unsubSkin = store.skin.subscribe(() => {
|
||||
paintSkins();
|
||||
syncCurrent();
|
||||
});
|
||||
const unsubTopbar = store.topbar.subscribe(syncAot);
|
||||
|
||||
paintSkins();
|
||||
syncCurrent();
|
||||
syncAot();
|
||||
|
||||
return () => {
|
||||
unsubSkin();
|
||||
unsubTopbar();
|
||||
grid.removeEventListener('click', onGridClick);
|
||||
grid.removeEventListener('keydown', onGridKeydown);
|
||||
aotInput.removeEventListener('change', onAotChange);
|
||||
aboutLink.removeEventListener('click', onAboutClick);
|
||||
};
|
||||
}
|
||||
220
src/renderer/components/TabStrip.ts
Normal file
220
src/renderer/components/TabStrip.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
import { store } from '../store';
|
||||
import { t } from '../i18n';
|
||||
import type { Route } from '../../shared/types';
|
||||
|
||||
interface TabItem {
|
||||
id: Route;
|
||||
label: string;
|
||||
svg: string;
|
||||
}
|
||||
|
||||
// 图标与 Sidebar.ts 保持一致(同一套 14×14 stroke=1.4 的 outline 系),
|
||||
// 这样后面若启用 Sidebar 二选一时视觉不会割裂。
|
||||
// 4 个图标各表一意,互不重叠:
|
||||
// clock — 圆面 + 时针分针
|
||||
// pomodoro — 番茄(pomodoro 意大利语即"番茄"):圆体 + 顶部三叶花萼
|
||||
// countdown — 沙漏:上下边框 + 两对角线 + 中段沙粒
|
||||
// alarms — 闹钟:圆顶铃身 + 外撇喇叭口 + 底部摆锤
|
||||
const ICONS = {
|
||||
clock: `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" aria-hidden="true">
|
||||
<circle cx="8" cy="8" r="6.5" />
|
||||
<path d="M8 4.5v3.6l2.4 1.4" stroke-linecap="round" />
|
||||
</svg>`,
|
||||
pomodoro: `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="8" cy="9.5" r="4.3" />
|
||||
<path d="M5.5 5.5L8 3l2.5 2.5" />
|
||||
<path d="M8 3v3" />
|
||||
</svg>`,
|
||||
countdown: `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M4 2.5h8" />
|
||||
<path d="M4 13.5h8" />
|
||||
<path d="M4 2.5L12 13.5" />
|
||||
<path d="M12 2.5L4 13.5" />
|
||||
<path d="M8 8v1.5" />
|
||||
</svg>`,
|
||||
alarms: `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M3 11s2-1.5 2-5.5a3 3 0 0 1 6 0c0 4 2 5.5 2 5.5h-10Z" />
|
||||
<path d="M7 13a1 1 0 0 0 2 0" />
|
||||
</svg>`
|
||||
};
|
||||
|
||||
const ITEMS: TabItem[] = [
|
||||
{ id: 'clock', label: t('nav.clock'), svg: ICONS.clock },
|
||||
{ id: 'pomodoro', label: t('nav.pomodoro'), svg: ICONS.pomodoro },
|
||||
{ id: 'countdown', label: t('nav.countdown'), svg: ICONS.countdown },
|
||||
{ id: 'alarms', label: t('nav.alarms'), svg: ICONS.alarms }
|
||||
];
|
||||
|
||||
export function renderTabStrip(root: HTMLElement): () => void {
|
||||
root.classList.add('tab-strip');
|
||||
root.setAttribute('role', 'tablist');
|
||||
// tablist 的 aria-label 是整个导航的名字,不是某一个 tab 的名字
|
||||
// (早先误设成 t('nav.clock')="时钟",屏读器会把整条导航念成"时钟")。
|
||||
root.setAttribute('aria-label', t('nav.mainTabs'));
|
||||
// 静态结构:list + 共用的滑动指示线节点 + 右侧淡出遮罩
|
||||
root.innerHTML = `
|
||||
<div class="tab-strip-list" role="presentation">
|
||||
${ITEMS.map(it => `
|
||||
<button class="tab-item"
|
||||
role="tab"
|
||||
data-route="${it.id}"
|
||||
type="button"
|
||||
aria-controls="route-panel">
|
||||
<span class="tab-item-mark">${it.svg}</span>
|
||||
<span class="tab-item-label">${it.label}</span>
|
||||
</button>
|
||||
`).join('')}
|
||||
</div>
|
||||
<span class="tab-indicator" aria-hidden="true"></span>
|
||||
`;
|
||||
|
||||
const buttons = Array.from(root.querySelectorAll<HTMLButtonElement>('.tab-item'));
|
||||
const list = root.querySelector<HTMLElement>('.tab-strip-list')!;
|
||||
const indicator = root.querySelector<HTMLElement>('.tab-indicator')!;
|
||||
|
||||
/** 永远只读当前 store,避免组件内部存过期状态。 */
|
||||
function sync(): void {
|
||||
const cur = store.route.get();
|
||||
// settings 不在 ITEMS 里 —— 当 route === 'settings' 时所有按钮都不 active,
|
||||
// 不能把所有 button.tabIndex 设 -1,否则整条 tablist 失去键盘可达性,
|
||||
// 键盘用户进入设置页后再也回不到主面板(除了点顶部齿轮按钮)。
|
||||
// 修复:保留"当前 active 或列表第一个"为 tabIndex=0,其他 -1。
|
||||
buttons.forEach(btn => {
|
||||
const active = btn.dataset.route === cur;
|
||||
btn.classList.toggle('active', active);
|
||||
btn.setAttribute('aria-selected', String(active));
|
||||
});
|
||||
// 让"列表第一个"作为 fallback focus 锚点(仅在没有任何 active 时)。
|
||||
const activeBtn = buttons.find(b => b.dataset.route === cur) ?? buttons[0]!;
|
||||
buttons.forEach(btn => {
|
||||
btn.tabIndex = btn === activeBtn ? 0 : -1;
|
||||
});
|
||||
// 指示线定位放 RAF 里:1) 让 class 切换先 paint 完 2) 拿到真实宽度
|
||||
requestAnimationFrame(() => moveIndicator(cur, false));
|
||||
// 选中 tab 偏离视口时滚回视口
|
||||
activeBtn.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'smooth' });
|
||||
}
|
||||
|
||||
/**
|
||||
* 滑动指示线。
|
||||
* initial=true 首次挂载:仅淡入,避免从 0 宽滑过来的突兀感
|
||||
* initial=false 切换:transform + width 同步 220ms cubic-bezier 平移
|
||||
* 没有目标时(理论不会发生)隐藏。
|
||||
*
|
||||
* 位置公式:
|
||||
* indicator 用 `position:absolute; left:0` 挂在 .tab-strip 下,left:0
|
||||
* 落在父元素的 padding-edge(不是 content-edge),所以 translateX 的
|
||||
* 原点 = stripRect.left —— 不要再去减 paddingLeft,那会双重扣减。
|
||||
* getBoundingClientRect 已反映 strip 的 scrollLeft,因此即使 tab 被横向
|
||||
* 滚出再滚回,指示线也会跟到视觉中心。用 Math.round 把 subpixel 抖动消除。
|
||||
*/
|
||||
function moveIndicator(route: Route, initial: boolean): void {
|
||||
const target = buttons.find(b => b.dataset.route === route);
|
||||
if (!target) {
|
||||
indicator.style.opacity = '0';
|
||||
return;
|
||||
}
|
||||
const tabRect = target.getBoundingClientRect();
|
||||
const stripRect = root.getBoundingClientRect();
|
||||
|
||||
// 胶囊感:指示线宽度 = 选中 tab 宽度的 60%,最小 20px
|
||||
const w = Math.max(20, tabRect.width * 0.6);
|
||||
// 中心对齐:tab 视觉中心 - indicator 中心 - strip 左缘
|
||||
const x = Math.round(
|
||||
(tabRect.left + tabRect.width / 2) - (stripRect.left + w / 2)
|
||||
);
|
||||
indicator.style.width = `${w}px`;
|
||||
indicator.style.transform = `translateX(${x}px)`;
|
||||
indicator.style.opacity = '1';
|
||||
indicator.style.transition = initial
|
||||
? 'opacity 220ms cubic-bezier(0.22, 1, 0.36, 1)'
|
||||
: 'transform 220ms cubic-bezier(0.22, 1, 0.36, 1), width 220ms cubic-bezier(0.22, 1, 0.36, 1)';
|
||||
}
|
||||
|
||||
/**
|
||||
* 只在真正能滚动的一侧才淡出。
|
||||
* 4 个 tab 通常不溢出,若无条件挂 mask,最左的"时钟"会被永久压暗,
|
||||
* 看起来像有一层阴影挡住了图标。
|
||||
*/
|
||||
function syncFade(): void {
|
||||
const max = root.scrollWidth - root.clientWidth;
|
||||
// 1px 容差:subpixel 布局下 scrollWidth 常比 clientWidth 大零点几
|
||||
const overflowing = max > 1;
|
||||
const left = overflowing && root.scrollLeft > 1;
|
||||
const right = overflowing && root.scrollLeft < max - 1;
|
||||
list.style.setProperty('--tab-fade-start', left ? '24px' : '0px');
|
||||
list.style.setProperty('--tab-fade-end', right ? '24px' : '0px');
|
||||
}
|
||||
|
||||
// ---- 交互 ----
|
||||
// handler 提到外层取名:cleanup 时 removeEventListener 要拿到同一引用,
|
||||
// 匿名箭头就算长一样也无法匹配;统一命名也方便阅读。
|
||||
function onTabClick(e: MouseEvent): void {
|
||||
const target = e.target as HTMLElement;
|
||||
const btn = target.closest<HTMLButtonElement>('.tab-item');
|
||||
if (!btn) return;
|
||||
const route = btn.dataset.route as Route | undefined;
|
||||
if (route) store.route.set(route);
|
||||
}
|
||||
function onTabKeydown(e: KeyboardEvent): void {
|
||||
// settings 不在 ITEMS 里:cur === 'settings' 时 idx = -1。
|
||||
// 早先直接 return → 进入设置页后 ←/→/Home/End 全失效。
|
||||
// 修复:cur 不在 ITEMS 时,把"当前 focus 的按钮索引"作为锚点,没有就 fallback 0。
|
||||
const cur = store.route.get();
|
||||
let idx = ITEMS.findIndex(it => it.id === cur);
|
||||
if (idx < 0) {
|
||||
const focused = document.activeElement as HTMLButtonElement | null;
|
||||
const fi = focused ? buttons.indexOf(focused) : -1;
|
||||
idx = fi >= 0 ? fi : 0;
|
||||
}
|
||||
|
||||
let nextIdx = -1;
|
||||
switch (e.key) {
|
||||
case 'ArrowLeft': nextIdx = (idx - 1 + ITEMS.length) % ITEMS.length; break;
|
||||
case 'ArrowRight': nextIdx = (idx + 1) % ITEMS.length; break;
|
||||
case 'Home': nextIdx = 0; break;
|
||||
case 'End': nextIdx = ITEMS.length - 1; break;
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
// focus 已经在 tab 上时,浏览器默认 click,不用额外处理
|
||||
return;
|
||||
default: return;
|
||||
}
|
||||
e.preventDefault();
|
||||
const next = ITEMS[nextIdx]!;
|
||||
store.route.set(next.id);
|
||||
buttons[nextIdx]?.focus();
|
||||
}
|
||||
root.addEventListener('click', onTabClick);
|
||||
|
||||
/**
|
||||
* 键盘导航:←/→ 切换一个;Home/End 跳首尾;Enter/Space 激活当前 focus 那个。
|
||||
* 选中后焦点跟到新 tab 上(roving tabindex 模式)。
|
||||
*/
|
||||
root.addEventListener('keydown', onTabKeydown);
|
||||
|
||||
// 视口尺寸变化(窗口拖宽、缩放)时保持指示线对齐
|
||||
const ro = new ResizeObserver(() => {
|
||||
moveIndicator(store.route.get(), false);
|
||||
syncFade();
|
||||
});
|
||||
ro.observe(list);
|
||||
ro.observe(root);
|
||||
|
||||
const onScroll = (): void => syncFade();
|
||||
root.addEventListener('scroll', onScroll, { passive: true });
|
||||
|
||||
const unsubRoute = store.route.subscribe(sync);
|
||||
sync();
|
||||
// 首次挂载走 initial 路径(淡入)
|
||||
moveIndicator(store.route.get(), true);
|
||||
syncFade();
|
||||
|
||||
return () => {
|
||||
unsubRoute();
|
||||
ro.disconnect();
|
||||
root.removeEventListener('scroll', onScroll);
|
||||
root.removeEventListener('click', onTabClick);
|
||||
root.removeEventListener('keydown', onTabKeydown);
|
||||
};
|
||||
}
|
||||
243
src/renderer/components/Toast.ts
Normal file
243
src/renderer/components/Toast.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
// 极简 toast 通知:替代 alert()。在应用根挂一次,所有地方都可调 toast()。
|
||||
// 设计要点:
|
||||
// - 不依赖任何状态库,纯 DOM + setTimeout;
|
||||
// - 同种 kind 的连续 toast 在 250ms 内会被合并,避免短时间内刷屏;
|
||||
// - 失败 / 警告类的 toast 默认 5s(比 info 长一些),给用户时间看清楚。
|
||||
// - 多 host 支持:主窗和编辑器各自调 mountToastHost(),互不影响——编辑器启动
|
||||
// 不会再"抢走"主窗的 host 导致主窗后续 toast 不可见(F-17 修复)。
|
||||
// - 按 kind 分流:error / warn 进顶部居中"打断"位;info / ok 进右下浮层。
|
||||
import { t } from '../i18n';
|
||||
|
||||
export type ToastKind = 'info' | 'ok' | 'warn' | 'error';
|
||||
|
||||
interface ToastItem {
|
||||
id: number;
|
||||
kind: ToastKind;
|
||||
message: string; // 纯文本 —— render 时走 textContent 注入,杜绝 innerHTML 路径下的 XSS
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
interface HostCtx {
|
||||
items: ToastItem[];
|
||||
lastByKind: Map<ToastKind, number>;
|
||||
cleanupTimer: number | null;
|
||||
/** 容器节点(含 .is-critical 类) */
|
||||
el: HTMLElement;
|
||||
/** 这个 host 接收的 kind 集合('all' = 全部) */
|
||||
accepts: 'all' | ToastKind[];
|
||||
}
|
||||
|
||||
class ToastBus {
|
||||
private hosts: HostCtx[] = [];
|
||||
private nextId = 1;
|
||||
|
||||
/**
|
||||
* 在 root 下挂两个 host:critical(error/warn,顶部居中)和 normal(info/ok,右下)。
|
||||
* 解绑时整体一起卸载。
|
||||
* 多入口共存的场景(主窗 + 编辑器)由调用方各调一次 attach,互不干扰。
|
||||
*
|
||||
* 幂等:同一 document 里已有 host(id 探测),则不再 attach,返回 noop cleanup。
|
||||
* 这样保证主窗 mount 期间 main.ts 在 document.body 上预挂的 host 不会被
|
||||
* App.ts 在 #app 子树里再挂一份,避免一次 toast 在 4 处同时显示。
|
||||
* 不同 BrowserWindow 的渲染进程各自一份 bus,document 隔离不影响。
|
||||
*/
|
||||
attach(root: HTMLElement): () => void {
|
||||
if (document.getElementById('toast-host-critical') || document.getElementById('toast-host-normal')) {
|
||||
return () => {};
|
||||
}
|
||||
const critical = document.createElement('div');
|
||||
critical.id = 'toast-host-critical';
|
||||
root.appendChild(critical);
|
||||
|
||||
const normal = document.createElement('div');
|
||||
normal.id = 'toast-host-normal';
|
||||
root.appendChild(normal);
|
||||
|
||||
const ctxs: HostCtx[] = [
|
||||
{ items: [], lastByKind: new Map(), cleanupTimer: null, el: critical, accepts: ['error', 'warn'] },
|
||||
{ items: [], lastByKind: new Map(), cleanupTimer: null, el: normal, accepts: ['info', 'ok'] }
|
||||
];
|
||||
this.hosts.push(...ctxs);
|
||||
ctxs.forEach(c => {
|
||||
c.el.classList.add('toast-host');
|
||||
// 只有承载 'error' 的 host 才是"打断"位;info/ok host 不要这个类
|
||||
// 以免被 CSS 当 critical 容器撑起顶部留白(参考 global.css .toast-host.is-critical)。
|
||||
if (c.accepts.includes('error')) c.el.classList.add('is-critical');
|
||||
this.render(c);
|
||||
});
|
||||
return () => this.detach(ctxs);
|
||||
}
|
||||
|
||||
private detach(ctxs: HostCtx[]): void {
|
||||
for (const ctx of ctxs) {
|
||||
if (ctx.cleanupTimer !== null) {
|
||||
window.clearTimeout(ctx.cleanupTimer);
|
||||
ctx.cleanupTimer = null;
|
||||
}
|
||||
ctx.el.remove();
|
||||
const i = this.hosts.indexOf(ctx);
|
||||
if (i >= 0) this.hosts.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
show(message: string, kind: ToastKind = 'info', ms?: number): void {
|
||||
// 无 host 时(例如测试或挂载之前)静默忽略:toast 不应崩溃主流程。
|
||||
if (this.hosts.length === 0) return;
|
||||
const lifetime = ms ?? this.defaultMs(kind);
|
||||
const now = Date.now();
|
||||
for (const ctx of this.hosts) {
|
||||
if (!this.accepts(ctx, kind)) continue;
|
||||
// 合并:同 kind 在 250ms 内连击 → 替换上一条而不是新开一条
|
||||
const lastAt = ctx.lastByKind.get(kind) ?? 0;
|
||||
if (now - lastAt < 250) {
|
||||
const idx = ctx.items.findIndex(i => i.kind === kind);
|
||||
if (idx >= 0) {
|
||||
ctx.items[idx]!.message = message;
|
||||
ctx.items[idx]!.expiresAt = now + lifetime;
|
||||
ctx.lastByKind.set(kind, now);
|
||||
this.render(ctx);
|
||||
this.scheduleCleanup(ctx);
|
||||
// continue 而不是 return:本类支持多个 host(主窗 + 编辑器各 attach 一次),
|
||||
// return 会让后面的 host 一条都收不到。
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const item: ToastItem = {
|
||||
id: this.nextId++,
|
||||
kind,
|
||||
message,
|
||||
expiresAt: now + lifetime
|
||||
};
|
||||
ctx.items.push(item);
|
||||
ctx.lastByKind.set(kind, now);
|
||||
this.render(ctx);
|
||||
this.scheduleCleanup(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
private accepts(ctx: HostCtx, kind: ToastKind): boolean {
|
||||
if (ctx.accepts === 'all') return true;
|
||||
return ctx.accepts.includes(kind);
|
||||
}
|
||||
|
||||
dismiss(id: number): void {
|
||||
for (const ctx of this.hosts) {
|
||||
const el = ctx.el.querySelector<HTMLElement>(`.toast[data-id="${id}"]`);
|
||||
if (el && el.isConnected) {
|
||||
el.classList.add('is-leaving');
|
||||
window.setTimeout(() => {
|
||||
const idx = ctx.items.findIndex(i => i.id === id);
|
||||
if (idx >= 0) {
|
||||
ctx.items.splice(idx, 1);
|
||||
this.render(ctx);
|
||||
}
|
||||
}, 180);
|
||||
return;
|
||||
}
|
||||
const idx = ctx.items.findIndex(i => i.id === id);
|
||||
if (idx >= 0) {
|
||||
ctx.items.splice(idx, 1);
|
||||
this.render(ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 清掉所有 host 上的所有 toast;同时清掉各自的 cleanupTimer,避免悬空。 */
|
||||
clear(): void {
|
||||
for (const ctx of this.hosts) {
|
||||
if (ctx.cleanupTimer !== null) {
|
||||
window.clearTimeout(ctx.cleanupTimer);
|
||||
ctx.cleanupTimer = null;
|
||||
}
|
||||
ctx.items = [];
|
||||
this.render(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
private defaultMs(kind: ToastKind): number {
|
||||
if (kind === 'error') return 6000;
|
||||
if (kind === 'warn') return 5000;
|
||||
if (kind === 'ok') return 2500;
|
||||
return 3500;
|
||||
}
|
||||
|
||||
private scheduleCleanup(ctx: HostCtx): void {
|
||||
if (ctx.cleanupTimer !== null) {
|
||||
window.clearTimeout(ctx.cleanupTimer);
|
||||
ctx.cleanupTimer = null;
|
||||
}
|
||||
// 找下一个最早过期的 toast 的剩余寿命作为本轮 wakeup 间隔 —— 否则设成 250ms 的话
|
||||
// 长寿命 toast(6s error)也要等 250ms 才能进入 leaving 动画。让 cleanup 直接睡到
|
||||
// "下一个要过期"的时刻,期间不发任何清理。
|
||||
const now = Date.now();
|
||||
const nextExpiry = ctx.items.reduce((m, it) => Math.min(m, it.expiresAt), Number.POSITIVE_INFINITY);
|
||||
const delay = nextExpiry === Number.POSITIVE_INFINITY
|
||||
? 250
|
||||
: Math.max(0, nextExpiry - now) + 200; // +200ms 留点过渡让 toastOut 跑完
|
||||
ctx.cleanupTimer = window.setTimeout(() => {
|
||||
const at = Date.now();
|
||||
let leaving = 0;
|
||||
for (const it of ctx.items) {
|
||||
if (it.expiresAt <= at && leaving < 8) {
|
||||
const el = ctx.el.querySelector<HTMLElement>(`.toast[data-id="${it.id}"]`);
|
||||
if (el && !el.classList.contains('is-leaving')) {
|
||||
el.classList.add('is-leaving');
|
||||
window.setTimeout(() => this.dismiss(it.id), 180);
|
||||
leaving++;
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.items = ctx.items.filter(i => i.expiresAt > at + 200);
|
||||
this.render(ctx);
|
||||
ctx.cleanupTimer = null;
|
||||
// 如果列表里还有未过期的 toast,重新调度下一轮 —— 早先 fix 漏了这一行,
|
||||
// 导致 setTimeout 一次性触发后 ctx.cleanupTimer 永久为 null,toast 永远不消失。
|
||||
if (ctx.items.length > 0) this.scheduleCleanup(ctx);
|
||||
}, delay);
|
||||
}
|
||||
|
||||
private render(ctx: HostCtx): void {
|
||||
// 用 DOM API 重建,杜绝 innerHTML 下用户输入被解析为 HTML 的风险;
|
||||
// 全部字符串走 textContent / setAttribute('aria-label', ...) 等安全接口。
|
||||
ctx.el.replaceChildren(...ctx.items.map(i => {
|
||||
const row = document.createElement('div');
|
||||
row.className = `toast toast-${i.kind}`;
|
||||
row.dataset.id = String(i.id);
|
||||
row.setAttribute('role', 'status');
|
||||
row.setAttribute('aria-live', 'polite');
|
||||
|
||||
const dot = document.createElement('span');
|
||||
dot.className = 'toast-dot';
|
||||
dot.setAttribute('aria-hidden', 'true');
|
||||
row.appendChild(dot);
|
||||
|
||||
const msg = document.createElement('span');
|
||||
msg.className = 'toast-msg';
|
||||
msg.textContent = i.message;
|
||||
row.appendChild(msg);
|
||||
|
||||
const close = document.createElement('button');
|
||||
close.className = 'toast-x';
|
||||
close.setAttribute('aria-label', t('toast.close'));
|
||||
close.textContent = '×'; // "×",不用 HTML 实体
|
||||
close.addEventListener('click', () => this.dismiss(i.id));
|
||||
row.appendChild(close);
|
||||
|
||||
return row;
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// 单例:模块级私有,外部只通过 toast() / mountToastHost() 间接调用,
|
||||
// 避免外部直接操作内部状态。
|
||||
const bus = new ToastBus();
|
||||
|
||||
/** 简写:toast(msg, kind?) — 内部转 bus.show */
|
||||
export function toast(message: string, kind: ToastKind = 'info', ms?: number): void {
|
||||
bus.show(message, kind, ms);
|
||||
}
|
||||
|
||||
/** 在 App.ts mount 时调一次挂载点。返回的函数可卸载整个 host。 */
|
||||
export function mountToastHost(root: HTMLElement): () => void {
|
||||
return bus.attach(root);
|
||||
}
|
||||
191
src/renderer/components/Topbar.ts
Normal file
191
src/renderer/components/Topbar.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import { t } from '../i18n';
|
||||
import { renderPinButton } from './PinButton';
|
||||
import { store } from '../store';
|
||||
import type { FloatingKind, Route } from '../../shared/types';
|
||||
|
||||
const TITLE_KEY = 'app.brand' as const;
|
||||
|
||||
// === Icon System ===
|
||||
// 全部图标统一:16×16 viewBox · stroke 1.4 · linecap/linejoin round · currentColor。
|
||||
// 与 TabStrip / Sidebar 同源,避免视觉割裂。CSS 端按容器尺寸归一到 14×14 显示。
|
||||
// 浮窗的 4 个图标与 TabStrip 的 4 个语义一一对应,造型一致:
|
||||
// floatingClock — 圆面 + 时针分针
|
||||
// floatingPomodoro — 番茄:圆体 + 顶部三叶花萼
|
||||
// floatingCountdown — 沙漏:上下边框 + 两对角线 + 中段沙粒
|
||||
// floatingAlarms — 闹钟:圆顶铃身 + 外撇喇叭口 + 底部摆锤
|
||||
const ICONS = {
|
||||
// 窗口按钮:14px 视觉尺寸配 16×16 viewBox,居中
|
||||
winMin: `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" aria-hidden="true">
|
||||
<path d="M3.5 11h9" />
|
||||
</svg>`,
|
||||
winMax: `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" aria-hidden="true">
|
||||
<rect x="3.5" y="3.5" width="9" height="9" rx="1.2" />
|
||||
</svg>`,
|
||||
winClose: `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" aria-hidden="true">
|
||||
<path d="M4 4l8 8M12 4l-8 8" />
|
||||
</svg>`,
|
||||
// 齿轮:Feather/Lucide 的标准 settings 齿轮(原 24×24 坐标整体 ×2/3 缩到 16×16
|
||||
// 视口,与本文件其它图标同一规格)。原先那版是"圆 + 8 根放射短线",在 14px 下
|
||||
// 读起来是太阳/亮度,不是设置,故替换为公认造型。
|
||||
settings: `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="8" cy="8" r="2" />
|
||||
<path d="M12.93 10a1.1 1.1 0 0 0 .22 1.21l.04.04a1.33 1.33 0 0 1 0 1.89 1.33 1.33 0 0 1-1.89 0l-.04-.04a1.1 1.1 0 0 0-1.21-.22 1.1 1.1 0 0 0-.67 1.01V14a1.33 1.33 0 0 1-1.33 1.33A1.33 1.33 0 0 1 6.72 14v-.06A1.1 1.1 0 0 0 6 12.93a1.1 1.1 0 0 0-1.21.22l-.04.04a1.33 1.33 0 0 1-1.89 0 1.33 1.33 0 0 1 0-1.89l.04-.04a1.1 1.1 0 0 0 .22-1.21 1.1 1.1 0 0 0-1.01-.67H2a1.33 1.33 0 0 1-1.33-1.33A1.33 1.33 0 0 1 2 6.72h.06A1.1 1.1 0 0 0 3.07 6a1.1 1.1 0 0 0-.22-1.21l-.04-.04a1.33 1.33 0 0 1 0-1.89 1.33 1.33 0 0 1 1.89 0l.04.04a1.1 1.1 0 0 0 1.21.22H6a1.1 1.1 0 0 0 .67-1.01V2A1.33 1.33 0 0 1 8 .67a1.33 1.33 0 0 1 1.33 1.33v.06a1.1 1.1 0 0 0 .67 1.01 1.1 1.1 0 0 0 1.21-.22l.04-.04a1.33 1.33 0 0 1 1.89 0 1.33 1.33 0 0 1 0 1.89l-.04.04a1.1 1.1 0 0 0-.22 1.21V6a1.1 1.1 0 0 0 1.01.67H14a1.33 1.33 0 0 1 1.33 1.33 1.33 1.33 0 0 1-1.33 1.33h-.06a1.1 1.1 0 0 0-1.01.67z" />
|
||||
</svg>`,
|
||||
// 浮窗时钟:传统时钟 + 12/3/6/9 + 指针
|
||||
floatingClock: `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="8" cy="8" r="5.6" />
|
||||
<path d="M8 4.6V8l2.4 1.4" />
|
||||
</svg>`,
|
||||
// 浮窗番茄钟:番茄(pomodoro 意大利语即"番茄")—— 圆体 + 顶部三叶花萼
|
||||
floatingPomodoro: `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="8" cy="9.5" r="4.3" />
|
||||
<path d="M5.5 5.5L8 3l2.5 2.5" />
|
||||
<path d="M8 3v3" />
|
||||
</svg>`,
|
||||
// 浮窗闹钟:圆顶铃身 + 外撇喇叭口 + 底部摆锤
|
||||
floatingAlarms: `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M3 11s2-1.5 2-5.5a3 3 0 0 1 6 0c0 4 2 5.5 2 5.5h-10Z" />
|
||||
<path d="M7 13a1 1 0 0 0 2 0" />
|
||||
</svg>`,
|
||||
// 浮窗倒计时:沙漏 —— 上下边框 + 两对角线 + 中段沙粒
|
||||
floatingCountdown: `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M4 2.5h8" />
|
||||
<path d="M4 13.5h8" />
|
||||
<path d="M4 2.5L12 13.5" />
|
||||
<path d="M12 2.5L4 13.5" />
|
||||
<path d="M8 8v1.5" />
|
||||
</svg>`
|
||||
};
|
||||
|
||||
// 4 个浮窗按钮的元数据。顺序与托盘菜单一致:时钟 → 番茄钟 → 倒计时 → 闹钟。
|
||||
const FLOATING_BUTTONS: Array<{ kind: FloatingKind; icon: keyof typeof ICONS; tooltipKey: string; btnId: string }> = [
|
||||
{ kind: 'clock', icon: 'floatingClock', tooltipKey: 'topbar.floatingClock', btnId: 'topbar-floating-clock' },
|
||||
{ kind: 'pomodoro', icon: 'floatingPomodoro', tooltipKey: 'topbar.floatingPomodoro', btnId: 'topbar-floating-pomodoro' },
|
||||
{ kind: 'countdown', icon: 'floatingCountdown', tooltipKey: 'topbar.floatingCountdown', btnId: 'topbar-floating-countdown' },
|
||||
{ kind: 'alarms', icon: 'floatingAlarms', tooltipKey: 'topbar.floatingAlarms', btnId: 'topbar-floating-alarms' }
|
||||
];
|
||||
|
||||
export function renderTopbar(root: HTMLElement): () => void {
|
||||
root.classList.add('topbar');
|
||||
const floatingBtns = FLOATING_BUTTONS.map(b => `
|
||||
<button class="topbar-iconbtn floating-btn" id="${b.btnId}" data-kind="${b.kind}"
|
||||
title="${t(b.tooltipKey as Parameters<typeof t>[0])}"
|
||||
aria-label="${t(b.tooltipKey as Parameters<typeof t>[0])}">
|
||||
<span class="topbar-icon" aria-hidden="true">${ICONS[b.icon]}</span>
|
||||
</button>
|
||||
`).join('');
|
||||
root.innerHTML = `
|
||||
<div class="topbar-title">
|
||||
<span class="topbar-title-main" id="topbar-title"></span>
|
||||
</div>
|
||||
<div class="topbar-actions" id="topbar-actions">
|
||||
<div class="topbar-floating-group" role="group" aria-label="${t('topbar.floatingGroup')}">
|
||||
${floatingBtns}
|
||||
</div>
|
||||
<div id="topbar-pin" class="topbar-pin-slot"></div>
|
||||
<button class="topbar-iconbtn" id="topbar-settings" title="${t('topbar.settings')}" aria-label="${t('topbar.settings')}">
|
||||
<span class="topbar-icon" aria-hidden="true">${ICONS.settings}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="topbar-winbtns" id="topbar-winbtns" aria-label="窗口控制" hidden>
|
||||
<button class="winbtn" id="winbtn-min" title="最小化" aria-label="最小化">
|
||||
<span class="topbar-icon" aria-hidden="true">${ICONS.winMin}</span>
|
||||
</button>
|
||||
<button class="winbtn" id="winbtn-max" title="最大化" aria-label="最大化">
|
||||
<span class="topbar-icon" aria-hidden="true">${ICONS.winMax}</span>
|
||||
</button>
|
||||
<button class="winbtn winbtn-close" id="winbtn-close" title="关闭" aria-label="关闭">
|
||||
<span class="topbar-icon" aria-hidden="true">${ICONS.winClose}</span>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
const title = root.querySelector<HTMLElement>('#topbar-title')!;
|
||||
const pinSlot = root.querySelector<HTMLElement>('#topbar-pin')!;
|
||||
const cleanupPin = renderPinButton(pinSlot);
|
||||
|
||||
// 设置按钮:toggle 语义 —— 进设置页 / 再点一次回到进来之前那个面板。
|
||||
// 点亮态与当前路由联动,键盘可达 + aria-pressed 同步。
|
||||
const settingsBtn = root.querySelector<HTMLButtonElement>('#topbar-settings')!;
|
||||
// 记住"从哪个面板进的设置"。托盘/单实例唤醒也可能直接把路由设成 settings,
|
||||
// 那时这里保留的仍是上一个非设置面板,退出时回得去。
|
||||
let lastPanelRoute: Route = store.route.get() === 'settings' ? 'clock' : store.route.get();
|
||||
function syncSettingsBtn(route: Route): void {
|
||||
if (route !== 'settings') lastPanelRoute = route;
|
||||
const on = route === 'settings';
|
||||
settingsBtn.classList.toggle('on', on);
|
||||
settingsBtn.setAttribute('aria-pressed', String(on));
|
||||
}
|
||||
const unsubRoute = store.route.subscribe(syncSettingsBtn);
|
||||
const onSettingsClick = (): void => {
|
||||
store.route.set(store.route.get() === 'settings' ? lastPanelRoute : 'settings');
|
||||
};
|
||||
settingsBtn.addEventListener('click', onSettingsClick);
|
||||
syncSettingsBtn(store.route.get());
|
||||
|
||||
// 4 个浮窗按钮:点击 toggle 对应浮窗;on 状态由 store.floatingOpen 驱动
|
||||
// (主进程通过 floatingWindowsChanged 推送)。键盘焦点态沿用 .topbar-iconbtn.on 的样式。
|
||||
// 缓存按钮引用:floatingOpen 推送(开/关浮窗、托盘 refresh)每条都会触发 subscribe,
|
||||
// 每条都 4 次 querySelector 没意义 —— 这里只查一次,循环里直接复用。
|
||||
const floatingBtnEls = FLOATING_BUTTONS.map(b => ({
|
||||
kind: b.kind,
|
||||
btn: root.querySelector<HTMLButtonElement>(`#${b.btnId}`)!,
|
||||
onClick: () => { void window.api.windows.toggleFloating(b.kind); }
|
||||
}));
|
||||
const unsubFloating = store.floatingOpen.subscribe((open) => {
|
||||
for (const { kind, btn } of floatingBtnEls) {
|
||||
const isOn = !!open[kind];
|
||||
// 状态没变就不写 DOM:toggle/aria 在高频 broadcast 下会触发多次重排
|
||||
// (floatingWindowsChanged 推一次,storageChanged 又推一次,等等)。
|
||||
const prevOn = btn.classList.contains('on');
|
||||
if (prevOn === isOn) continue;
|
||||
btn.classList.toggle('on', isOn);
|
||||
btn.setAttribute('aria-pressed', String(isOn));
|
||||
}
|
||||
});
|
||||
|
||||
for (const { btn, onClick } of floatingBtnEls) {
|
||||
btn.addEventListener('click', onClick);
|
||||
}
|
||||
|
||||
const winbtns = root.querySelector<HTMLElement>('#topbar-winbtns')!;
|
||||
// Win32 上的窗口按钮:onclick 通过 named 引用收集,cleanup 时一起置 null 让 GC 释放。
|
||||
let winBtnHandlers: Array<{ btn: HTMLButtonElement; handler: () => void }> | null = null;
|
||||
if (window.api.platform === 'win32') {
|
||||
winbtns.hidden = false;
|
||||
const minBtn = root.querySelector<HTMLButtonElement>('#winbtn-min')!;
|
||||
const maxBtn = root.querySelector<HTMLButtonElement>('#winbtn-max')!;
|
||||
const closeBtn = root.querySelector<HTMLButtonElement>('#winbtn-close')!;
|
||||
const onMin = (): void => { void window.api.windows.minimize(); };
|
||||
const onMax = (): void => { void window.api.windows.toggleMaximize(); };
|
||||
const onWinClose = (): void => { void window.api.windows.close(); };
|
||||
minBtn.onclick = onMin;
|
||||
maxBtn.onclick = onMax;
|
||||
closeBtn.onclick = onWinClose;
|
||||
winBtnHandlers = [
|
||||
{ btn: minBtn, handler: onMin },
|
||||
{ btn: maxBtn, handler: onMax },
|
||||
{ btn: closeBtn, handler: onWinClose }
|
||||
];
|
||||
}
|
||||
|
||||
// 标题不再随 route 变化(设置页已移除),挂载时设一次即可。
|
||||
title.textContent = t(TITLE_KEY as Parameters<typeof t>[0]);
|
||||
|
||||
return () => {
|
||||
unsubRoute();
|
||||
unsubFloating();
|
||||
settingsBtn.removeEventListener('click', onSettingsClick);
|
||||
for (const { btn, onClick } of floatingBtnEls) {
|
||||
btn.removeEventListener('click', onClick);
|
||||
}
|
||||
if (winBtnHandlers) {
|
||||
for (const { btn, handler } of winBtnHandlers) {
|
||||
btn.onclick = null;
|
||||
}
|
||||
winBtnHandlers = null;
|
||||
}
|
||||
// PinButton 自己挂了一个 store.topbar 订阅 + click listener,
|
||||
// 不调它的 cleanup 会让 listener 在下次 mount 时叠加。
|
||||
cleanupPin();
|
||||
};
|
||||
}
|
||||
14
src/renderer/components/escape.ts
Normal file
14
src/renderer/components/escape.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
// 共享的 HTML 转义工具,供多个组件复用。
|
||||
// 注意:这是给 textContent / 标签属性 / 内联子节点使用的最小集,
|
||||
// 不要把它套到 URL、style 等需要更严格语境处理的场景。
|
||||
const HTML_ESCAPE_MAP: Record<string, string> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
};
|
||||
|
||||
export function escapeHtml(s: string): string {
|
||||
return s.replace(/[&<>"']/g, c => HTML_ESCAPE_MAP[c]!);
|
||||
}
|
||||
167
src/renderer/floating-alarms/index.html
Normal file
167
src/renderer/floating-alarms/index.html
Normal file
@@ -0,0 +1,167 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; media-src 'self' file:" />
|
||||
<title>闹钟</title>
|
||||
<style>
|
||||
/* === 浮窗专用 CSS 变量(6 皮肤,每皮肤只声明浮窗实际用到的 16 个) ===
|
||||
原先加载 global.css + themes.css 合并产物 ~75 KB,实际只用 ~5%。
|
||||
内联后 0 round-trip,且不依赖主进程预创建后的额外资源请求。 */
|
||||
:root {
|
||||
--bg-elev-2: #13141A;
|
||||
--bg-elev-3: #1A1B22;
|
||||
--line: rgba(255, 255, 255, 0.06);
|
||||
--text: #F2F2F5;
|
||||
--text-dim: #B4B4BC;
|
||||
--text-faint: #9090A0;
|
||||
--accent: #5E6AD2;
|
||||
--accent-soft: rgba(94, 106, 210, 0.14);
|
||||
--accent-line: rgba(94, 106, 210, 0.40);
|
||||
--accent-glow: rgba(94, 106, 210, 0.20);
|
||||
--accent-2: #7C8AE5;
|
||||
--warn: #D9695E;
|
||||
--warn-soft: rgba(217, 105, 94, 0.12);
|
||||
--warn-line: rgba(217, 105, 94, 0.40);
|
||||
--ok: #4CB782;
|
||||
--ok-soft: rgba(76, 183, 130, 0.14);
|
||||
--ok-line: rgba(76, 183, 130, 0.40);
|
||||
--shadow-2: 0 12px 28px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
:root[data-skin="brass-dark"] {
|
||||
--bg-elev-2: #2B231B;
|
||||
--bg-elev-3: #38291E;
|
||||
--line: rgba(255, 235, 200, 0.08);
|
||||
--text: #F1E4C7;
|
||||
--text-dim: #C9B998;
|
||||
--text-faint: #8A7E66;
|
||||
--accent: #D9B25F;
|
||||
--accent-soft: rgba(217, 178, 95, 0.14);
|
||||
--accent-line: rgba(217, 178, 95, 0.45);
|
||||
--accent-glow: rgba(217, 178, 95, 0.22);
|
||||
--accent-2: #E8C77A;
|
||||
--warn: #C97050;
|
||||
--warn-soft: rgba(201, 112, 80, 0.14);
|
||||
--warn-line: rgba(201, 112, 80, 0.45);
|
||||
--ok: #7BA47B;
|
||||
--ok-soft: rgba(123, 164, 123, 0.14);
|
||||
--ok-line: rgba(123, 164, 123, 0.45);
|
||||
--shadow-2: 0 12px 28px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
:root[data-skin="solarized-dark"] {
|
||||
--bg-elev-2: #0A4150;
|
||||
--bg-elev-3: #134E5E;
|
||||
--line: rgba(147, 161, 161, 0.20);
|
||||
--text: #EEE8D5;
|
||||
--text-dim: #93A1A1;
|
||||
--text-faint: #657B83;
|
||||
--accent: #268BD2;
|
||||
--accent-soft: rgba(38, 139, 210, 0.18);
|
||||
--accent-line: rgba(38, 139, 210, 0.45);
|
||||
--accent-glow: rgba(38, 139, 210, 0.22);
|
||||
--accent-2: #B58900;
|
||||
--warn: #DC322F;
|
||||
--warn-soft: rgba(220, 50, 47, 0.18);
|
||||
--warn-line: rgba(220, 50, 47, 0.45);
|
||||
--ok: #859900;
|
||||
--ok-soft: rgba(133, 153, 0, 0.18);
|
||||
--ok-line: rgba(133, 153, 0, 0.45);
|
||||
--shadow-2: 0 12px 28px rgba(0, 0, 0, 0.50);
|
||||
}
|
||||
:root[data-skin="nord"] {
|
||||
--bg-elev-2: #434C5E;
|
||||
--bg-elev-3: #4C566A;
|
||||
--line: rgba(229, 233, 240, 0.10);
|
||||
--text: #ECEFF4;
|
||||
--text-dim: #D8DEE9;
|
||||
--text-faint: #88909D;
|
||||
--accent: #88C0D0;
|
||||
--accent-soft: rgba(136, 192, 208, 0.18);
|
||||
--accent-line: rgba(136, 192, 208, 0.45);
|
||||
--accent-glow: rgba(136, 192, 208, 0.22);
|
||||
--accent-2: #81A1C1;
|
||||
--warn: #BF616A;
|
||||
--warn-soft: rgba(191, 97, 106, 0.18);
|
||||
--warn-line: rgba(191, 97, 106, 0.45);
|
||||
--ok: #A3BE8C;
|
||||
--ok-soft: rgba(163, 190, 140, 0.18);
|
||||
--ok-line: rgba(163, 190, 140, 0.45);
|
||||
--shadow-2: 0 12px 28px rgba(0, 0, 0, 0.50);
|
||||
}
|
||||
:root[data-skin="catppuccin"] {
|
||||
--bg-elev-2: #313244;
|
||||
--bg-elev-3: #45475A;
|
||||
--line: rgba(205, 214, 244, 0.10);
|
||||
--text: #CDD6F4;
|
||||
--text-dim: #BAC2DE;
|
||||
--text-faint: #6C7086;
|
||||
--accent: #CBA6F7;
|
||||
--accent-soft: rgba(203, 166, 247, 0.18);
|
||||
--accent-line: rgba(203, 166, 247, 0.45);
|
||||
--accent-glow: rgba(203, 166, 247, 0.22);
|
||||
--accent-2: #F38BA8;
|
||||
--warn: #F38BA8;
|
||||
--warn-soft: rgba(243, 139, 168, 0.18);
|
||||
--warn-line: rgba(243, 139, 168, 0.45);
|
||||
--ok: #A6E3A1;
|
||||
--ok-soft: rgba(166, 227, 161, 0.18);
|
||||
--ok-line: rgba(166, 227, 161, 0.45);
|
||||
--shadow-2: 0 12px 28px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
:root[data-skin="vercel-light"] {
|
||||
--bg-elev-2: #F4F4F5;
|
||||
--bg-elev-3: #E4E4E7;
|
||||
--line: rgba(20, 22, 38, 0.08);
|
||||
--text: #18181B;
|
||||
--text-dim: #50525A;
|
||||
--text-faint: #A1A1AA;
|
||||
--accent: #5E6AD2;
|
||||
--accent-soft: rgba(94, 106, 210, 0.10);
|
||||
--accent-line: rgba(94, 106, 210, 0.40);
|
||||
--accent-glow: rgba(94, 106, 210, 0.18);
|
||||
--accent-2: #6E78C7;
|
||||
--warn: #DC2626;
|
||||
--warn-soft: rgba(220, 38, 38, 0.10);
|
||||
--warn-line: rgba(220, 38, 38, 0.40);
|
||||
--ok: #16A34A;
|
||||
--ok-soft: rgba(22, 163, 74, 0.10);
|
||||
--ok-line: rgba(22, 163, 74, 0.40);
|
||||
--shadow-2: 0 12px 28px rgba(20, 22, 38, 0.10), 0 2px 4px rgba(20, 22, 38, 0.06);
|
||||
}
|
||||
/* 兼容旧 [data-theme] 写法(无 [data-skin])—— 落到 linear-dark 默认值 */
|
||||
:root[data-theme]:not([data-skin]) {
|
||||
--bg-elev-2: #13141A;
|
||||
--bg-elev-3: #1A1B22;
|
||||
}
|
||||
/* === Minimal reset: 浮窗专用精简版 ===
|
||||
background: transparent 是关键 —— 否则覆盖不住桌布,卡片会变黑底。
|
||||
font-family 不在这里写:会被各 floating-*/styles.css 的 html, body 块覆盖,
|
||||
而 styles.css 的 font-family 才是当前生效的版本。如果要加中文回退,应该改
|
||||
styles.css 而不是这里(否则被 styles.css 覆盖失效)。 */
|
||||
html, body {
|
||||
margin: 0; padding: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
overflow: hidden; height: 100vh;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
</style>
|
||||
<link rel="stylesheet" href="./styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root">
|
||||
<button id="close" title="关闭浮窗" aria-label="关闭浮窗">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true">
|
||||
<path d="M4 4l8 8M12 4l-8 8" />
|
||||
</svg>
|
||||
</button>
|
||||
<div id="label">下一个</div>
|
||||
<div id="time">--:--</div>
|
||||
<div id="countdown">—</div>
|
||||
<div id="title">—</div>
|
||||
</div>
|
||||
<script type="module" src="./main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
132
src/renderer/floating-alarms/main.ts
Normal file
132
src/renderer/floating-alarms/main.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
// 浮窗闹钟:240×150 紧凑卡片,展示下一个要触发的闹钟 + 距今多久。
|
||||
// - 拖拽走 OS(#root 的 -webkit-app-region: drag)
|
||||
// - 关闭路径与浮窗番茄钟一致:×/双击/Esc/右键/触屏长按
|
||||
//
|
||||
// 设计要点:
|
||||
// 1. 拉一次 getState 兜底初始化,然后订阅 storageChanged。
|
||||
// 2. 距下次触发的剩余时间用 1s setTimeout 重画(clockTick 已经每秒推送,复用之避免再加 interval)。
|
||||
// 3. 多个闹钟时,主标题展示"下一个",副标题展示"共 X 个启用"。
|
||||
// 4. 没有启用闹钟时,显示空态文案。
|
||||
|
||||
import { pickNextAlarm } from '../../shared/time';
|
||||
import { t } from '../i18n';
|
||||
import { attachFloatingWindowBehavior, subscribeFloatingTheme } from '../floating-shared';
|
||||
import type { Alarm } from '../../shared/types';
|
||||
|
||||
const root = document.getElementById('root')!;
|
||||
const labelEl = document.querySelector<HTMLElement>('#label')!;
|
||||
const timeEl = document.querySelector<HTMLElement>('#time')!;
|
||||
const countdownEl = document.querySelector<HTMLElement>('#countdown')!;
|
||||
const titleEl = document.querySelector<HTMLElement>('#title')!;
|
||||
|
||||
let enabled: Alarm[] = [];
|
||||
|
||||
/** 把毫秒差格式化为 "Xh Ym 后" / "Y 分后" / "Z 秒后"。文案走 i18n 键。 */
|
||||
function fmtDiff(ms: number): string {
|
||||
const sec = Math.max(1, Math.round(ms / 1000));
|
||||
if (sec < 60) return t('floating.alarms.diffSec', sec);
|
||||
const min = Math.round(sec / 60);
|
||||
if (min < 60) return t('floating.alarms.diffMin', min);
|
||||
const h = Math.floor(min / 60);
|
||||
const m = min % 60;
|
||||
return m > 0 ? t('floating.alarms.diffHM', h, m) : t('floating.alarms.diffH', h);
|
||||
}
|
||||
|
||||
/** "下一个要响" 缓存:pickNextAlarm 是 O(N),每帧 paint 调用会有冗余。
|
||||
* 250ms 内复用同一次结果,避免短时间内算两次。 */
|
||||
let pickCache: { at: number; result: { alarm: Alarm | null; nextAt: number } } | null = null;
|
||||
function pickNext(now = new Date()): { alarm: Alarm | null; nextAt: number } {
|
||||
if (pickCache && Date.now() - pickCache.at < 250) return pickCache.result;
|
||||
const result = pickNextAlarm(enabled, now);
|
||||
pickCache = { at: Date.now(), result };
|
||||
return result;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
// 上次画过的状态快照:只有真正变了才动 DOM。
|
||||
// 1Hz clockTick 每秒都会推一次;空态/无变化的渲染全靠此短路。
|
||||
let lastDiffText = '';
|
||||
let lastTimeText = '';
|
||||
let lastTitleText = '';
|
||||
let lastLabelText = '';
|
||||
let lastRootState: 'enabled' | 'empty' | null = null;
|
||||
|
||||
function paint(): void {
|
||||
if (cancelled) return;
|
||||
const total = enabled.length;
|
||||
if (total === 0) {
|
||||
if (lastRootState !== 'empty') {
|
||||
root.dataset.state = 'empty';
|
||||
lastRootState = 'empty';
|
||||
}
|
||||
if (lastLabelText !== 'def') { labelEl.textContent = t('floating.alarms.tagDefault'); lastLabelText = 'def'; }
|
||||
if (lastTimeText !== '——') { timeEl.textContent = '——'; lastTimeText = '——'; }
|
||||
if (lastDiffText !== 'empty') { countdownEl.textContent = t('floating.alarms.empty'); lastDiffText = 'empty'; }
|
||||
const cnt0 = t('floating.alarms.countN', 0);
|
||||
if (lastTitleText !== cnt0) { titleEl.textContent = cnt0; lastTitleText = cnt0; }
|
||||
return;
|
||||
}
|
||||
const { alarm, nextAt } = pickNext();
|
||||
if (!alarm) {
|
||||
// enabled 不空但没有 nextAt(理论上 nextFireAt 至少给一个)—— 走空态路径
|
||||
if (lastRootState !== 'empty') {
|
||||
root.dataset.state = 'empty';
|
||||
lastRootState = 'empty';
|
||||
}
|
||||
if (lastTimeText !== '——') { timeEl.textContent = '——'; lastTimeText = '——'; }
|
||||
if (lastDiffText !== 'empty') { countdownEl.textContent = t('floating.alarms.empty'); lastDiffText = 'empty'; }
|
||||
return;
|
||||
}
|
||||
if (lastRootState !== 'enabled') {
|
||||
root.dataset.state = 'enabled';
|
||||
lastRootState = 'enabled';
|
||||
}
|
||||
const diff = nextAt - Date.now();
|
||||
const nextLabel = t('floating.alarms.next');
|
||||
if (lastLabelText !== nextLabel) { labelEl.textContent = nextLabel; lastLabelText = nextLabel; }
|
||||
if (lastTimeText !== alarm.time) { timeEl.textContent = alarm.time; lastTimeText = alarm.time; }
|
||||
const diffText = fmtDiff(diff);
|
||||
if (lastDiffText !== diffText) { countdownEl.textContent = diffText; lastDiffText = diffText; }
|
||||
// 标题:标签 + 启用数量角标(多启用时显示总数)
|
||||
const tag = alarm.label || t('floating.alarms.tagDefault');
|
||||
const nextTitle = total > 1 ? t('floating.alarms.titleFmt', tag, total) : t('floating.alarms.titleOnly', tag);
|
||||
if (lastTitleText !== nextTitle) { titleEl.textContent = nextTitle; lastTitleText = nextTitle; }
|
||||
}
|
||||
|
||||
// 共用:右键菜单 / 双击关闭 / 触屏长按 / Esc / × 按钮
|
||||
attachFloatingWindowBehavior(root, 'alarms');
|
||||
|
||||
// 同步首屏 paint:此时 enabled=[] 走 total===0 分支,显示 i18n 默认空态文案
|
||||
// ("暂无闹钟"+"共 0 个启用"),而不是裸 HTML 默认占位符。await getState() 拿到
|
||||
// 真数据后,lambda 内会再 paint() 覆盖 —— 视觉上不会有"空态 → 正确"的两帧闪烁。
|
||||
paint();
|
||||
|
||||
// 主题订阅(与 pomodoro / countdown / clock 共用同一份实现)
|
||||
const unsubTheme = subscribeFloatingTheme();
|
||||
|
||||
// 所有 on.* 订阅统一收口,beforeunload 一次性释放 —— 与 floating-shared 的
|
||||
// unsubTheme 模式对齐,避免散落的未释放 listener 在长会话里堆积。
|
||||
const unsubs: Array<() => void> = [
|
||||
unsubTheme,
|
||||
window.api.on.storageChanged((s) => {
|
||||
enabled = s.alarms.filter((a) => a.enabled);
|
||||
// enabled 集合变了就必须让"下一个"重算:pickCache 命中 250ms 内的旧结果
|
||||
// 会让被删除/禁用的那条继续显示最长 250ms,给用户"按了删除还在"的错觉。
|
||||
pickCache = null;
|
||||
paint();
|
||||
}),
|
||||
// clockTick 每秒推一次;用它在每秒驱动 paint(不需要再加 setInterval)
|
||||
window.api.on.clockTick(() => paint())
|
||||
];
|
||||
|
||||
(async () => {
|
||||
const s = await window.api.clock.getState();
|
||||
enabled = s.alarms.filter((a: Alarm) => a.enabled);
|
||||
paint();
|
||||
})();
|
||||
|
||||
window.addEventListener('beforeunload', () => {
|
||||
cancelled = true;
|
||||
unsubs.forEach(fn => fn());
|
||||
});
|
||||
109
src/renderer/floating-alarms/styles.css
Normal file
109
src/renderer/floating-alarms/styles.css
Normal file
@@ -0,0 +1,109 @@
|
||||
/* Floating alarms — 240×150 card showing next alarm */
|
||||
html, body {
|
||||
margin: 0; padding: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-family: Inter, system-ui, sans-serif;
|
||||
overflow: hidden; height: 100vh;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
#root {
|
||||
position: relative;
|
||||
width: 240px; height: 150px;
|
||||
margin: 0;
|
||||
background: var(--bg-elev-2);
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--line);
|
||||
box-shadow: var(--shadow-2);
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
-webkit-app-region: drag;
|
||||
display: flex; flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 16px 18px;
|
||||
/* 空态:柔和边 */
|
||||
transition: border-color 160ms;
|
||||
}
|
||||
#root[data-state="enabled"] {
|
||||
border-color: var(--accent-line);
|
||||
box-shadow: var(--shadow-2), 0 0 0 1px var(--accent-line), 0 0 12px var(--accent-glow);
|
||||
}
|
||||
#root[data-state="empty"] {
|
||||
border-color: var(--line);
|
||||
/* 与 enabled 态有所区分,避免视觉空洞 */
|
||||
opacity: 0.9;
|
||||
}
|
||||
#root:active { cursor: grabbing; }
|
||||
|
||||
#close {
|
||||
position: absolute;
|
||||
top: 8px; right: 8px;
|
||||
width: 18px; height: 18px;
|
||||
display: grid; place-items: center;
|
||||
background: var(--line);
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
-webkit-app-region: no-drag;
|
||||
opacity: 0;
|
||||
transition: opacity 120ms, background 120ms, color 120ms, transform 80ms;
|
||||
}
|
||||
#close svg { width: 9px; height: 9px; display: block; }
|
||||
#root:hover #close { opacity: 1; }
|
||||
#close:hover { background: var(--warn-soft); color: var(--warn); }
|
||||
#close:active { transform: scale(0.9); }
|
||||
#root:focus-within #close { opacity: 1; }
|
||||
#close:focus-visible { opacity: 1; outline: 2px solid var(--accent-line); outline-offset: 2px; }
|
||||
@media (hover: none) {
|
||||
#close { opacity: 1; }
|
||||
}
|
||||
|
||||
#label {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: var(--text-faint);
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
transition: color 120ms;
|
||||
}
|
||||
#root[data-state="enabled"] #label { color: var(--accent); }
|
||||
|
||||
#time {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-weight: 600;
|
||||
font-size: 36px;
|
||||
color: var(--text);
|
||||
font-feature-settings: "tnum" 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: -1px;
|
||||
line-height: 1;
|
||||
/* 时间区占据视觉重心 */
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
#countdown {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-dim);
|
||||
margin-top: 2px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
#root[data-state="enabled"] #countdown { color: var(--accent-2); }
|
||||
|
||||
#title {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-faint);
|
||||
letter-spacing: 0.04em;
|
||||
/* 单行省略,避免长标签撑爆卡片 */
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-top: 2px;
|
||||
}
|
||||
167
src/renderer/floating-clock/index.html
Normal file
167
src/renderer/floating-clock/index.html
Normal file
@@ -0,0 +1,167 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<!-- 浮窗没显式 CSP 时由 BrowserWindow 兜底——但内嵌的 Audio 仍需要
|
||||
media-src 'self' file: 才能播 file:// 铃声。兜底写法与主窗对齐。 -->
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; media-src 'self' file:" />
|
||||
<title>时钟</title>
|
||||
<style>
|
||||
/* === 浮窗专用 CSS 变量(6 皮肤,每皮肤只声明浮窗实际用到的 16 个) ===
|
||||
原先加载 global.css + themes.css 合并产物 ~75 KB,实际只用 ~5%。
|
||||
内联后 0 round-trip,且不依赖主进程预创建后的额外资源请求。 */
|
||||
:root {
|
||||
--bg-elev-2: #13141A;
|
||||
--bg-elev-3: #1A1B22;
|
||||
--line: rgba(255, 255, 255, 0.06);
|
||||
--text: #F2F2F5;
|
||||
--text-dim: #B4B4BC;
|
||||
--text-faint: #9090A0;
|
||||
--accent: #5E6AD2;
|
||||
--accent-soft: rgba(94, 106, 210, 0.14);
|
||||
--accent-line: rgba(94, 106, 210, 0.40);
|
||||
--accent-glow: rgba(94, 106, 210, 0.20);
|
||||
--accent-2: #7C8AE5;
|
||||
--warn: #D9695E;
|
||||
--warn-soft: rgba(217, 105, 94, 0.12);
|
||||
--warn-line: rgba(217, 105, 94, 0.40);
|
||||
--ok: #4CB782;
|
||||
--ok-soft: rgba(76, 183, 130, 0.14);
|
||||
--ok-line: rgba(76, 183, 130, 0.40);
|
||||
--shadow-2: 0 12px 28px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
:root[data-skin="brass-dark"] {
|
||||
--bg-elev-2: #2B231B;
|
||||
--bg-elev-3: #38291E;
|
||||
--line: rgba(255, 235, 200, 0.08);
|
||||
--text: #F1E4C7;
|
||||
--text-dim: #C9B998;
|
||||
--text-faint: #8A7E66;
|
||||
--accent: #D9B25F;
|
||||
--accent-soft: rgba(217, 178, 95, 0.14);
|
||||
--accent-line: rgba(217, 178, 95, 0.45);
|
||||
--accent-glow: rgba(217, 178, 95, 0.22);
|
||||
--accent-2: #E8C77A;
|
||||
--warn: #C97050;
|
||||
--warn-soft: rgba(201, 112, 80, 0.14);
|
||||
--warn-line: rgba(201, 112, 80, 0.45);
|
||||
--ok: #7BA47B;
|
||||
--ok-soft: rgba(123, 164, 123, 0.14);
|
||||
--ok-line: rgba(123, 164, 123, 0.45);
|
||||
--shadow-2: 0 12px 28px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
:root[data-skin="solarized-dark"] {
|
||||
--bg-elev-2: #0A4150;
|
||||
--bg-elev-3: #134E5E;
|
||||
--line: rgba(147, 161, 161, 0.20);
|
||||
--text: #EEE8D5;
|
||||
--text-dim: #93A1A1;
|
||||
--text-faint: #657B83;
|
||||
--accent: #268BD2;
|
||||
--accent-soft: rgba(38, 139, 210, 0.18);
|
||||
--accent-line: rgba(38, 139, 210, 0.45);
|
||||
--accent-glow: rgba(38, 139, 210, 0.22);
|
||||
--accent-2: #B58900;
|
||||
--warn: #DC322F;
|
||||
--warn-soft: rgba(220, 50, 47, 0.18);
|
||||
--warn-line: rgba(220, 50, 47, 0.45);
|
||||
--ok: #859900;
|
||||
--ok-soft: rgba(133, 153, 0, 0.18);
|
||||
--ok-line: rgba(133, 153, 0, 0.45);
|
||||
--shadow-2: 0 12px 28px rgba(0, 0, 0, 0.50);
|
||||
}
|
||||
:root[data-skin="nord"] {
|
||||
--bg-elev-2: #434C5E;
|
||||
--bg-elev-3: #4C566A;
|
||||
--line: rgba(229, 233, 240, 0.10);
|
||||
--text: #ECEFF4;
|
||||
--text-dim: #D8DEE9;
|
||||
--text-faint: #88909D;
|
||||
--accent: #88C0D0;
|
||||
--accent-soft: rgba(136, 192, 208, 0.18);
|
||||
--accent-line: rgba(136, 192, 208, 0.45);
|
||||
--accent-glow: rgba(136, 192, 208, 0.22);
|
||||
--accent-2: #81A1C1;
|
||||
--warn: #BF616A;
|
||||
--warn-soft: rgba(191, 97, 106, 0.18);
|
||||
--warn-line: rgba(191, 97, 106, 0.45);
|
||||
--ok: #A3BE8C;
|
||||
--ok-soft: rgba(163, 190, 140, 0.18);
|
||||
--ok-line: rgba(163, 190, 140, 0.45);
|
||||
--shadow-2: 0 12px 28px rgba(0, 0, 0, 0.50);
|
||||
}
|
||||
:root[data-skin="catppuccin"] {
|
||||
--bg-elev-2: #313244;
|
||||
--bg-elev-3: #45475A;
|
||||
--line: rgba(205, 214, 244, 0.10);
|
||||
--text: #CDD6F4;
|
||||
--text-dim: #BAC2DE;
|
||||
--text-faint: #6C7086;
|
||||
--accent: #CBA6F7;
|
||||
--accent-soft: rgba(203, 166, 247, 0.18);
|
||||
--accent-line: rgba(203, 166, 247, 0.45);
|
||||
--accent-glow: rgba(203, 166, 247, 0.22);
|
||||
--accent-2: #F38BA8;
|
||||
--warn: #F38BA8;
|
||||
--warn-soft: rgba(243, 139, 168, 0.18);
|
||||
--warn-line: rgba(243, 139, 168, 0.45);
|
||||
--ok: #A6E3A1;
|
||||
--ok-soft: rgba(166, 227, 161, 0.18);
|
||||
--ok-line: rgba(166, 227, 161, 0.45);
|
||||
--shadow-2: 0 12px 28px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
:root[data-skin="vercel-light"] {
|
||||
--bg-elev-2: #F4F4F5;
|
||||
--bg-elev-3: #E4E4E7;
|
||||
--line: rgba(20, 22, 38, 0.08);
|
||||
--text: #18181B;
|
||||
--text-dim: #50525A;
|
||||
--text-faint: #A1A1AA;
|
||||
--accent: #5E6AD2;
|
||||
--accent-soft: rgba(94, 106, 210, 0.10);
|
||||
--accent-line: rgba(94, 106, 210, 0.40);
|
||||
--accent-glow: rgba(94, 106, 210, 0.18);
|
||||
--accent-2: #6E78C7;
|
||||
--warn: #DC2626;
|
||||
--warn-soft: rgba(220, 38, 38, 0.10);
|
||||
--warn-line: rgba(220, 38, 38, 0.40);
|
||||
--ok: #16A34A;
|
||||
--ok-soft: rgba(22, 163, 74, 0.10);
|
||||
--ok-line: rgba(22, 163, 74, 0.40);
|
||||
--shadow-2: 0 12px 28px rgba(20, 22, 38, 0.10), 0 2px 4px rgba(20, 22, 38, 0.06);
|
||||
}
|
||||
/* 兼容旧 [data-theme] 写法(无 [data-skin])—— 落到 linear-dark 默认值 */
|
||||
:root[data-theme]:not([data-skin]) {
|
||||
--bg-elev-2: #13141A;
|
||||
--bg-elev-3: #1A1B22;
|
||||
}
|
||||
/* === Minimal reset: 浮窗专用精简版 ===
|
||||
background: transparent 是关键 —— 否则覆盖不住桌布,卡片会变黑底。
|
||||
font-family 不在这里写:会被各 floating-*/styles.css 的 html, body 块覆盖,
|
||||
而 styles.css 的 font-family 才是当前生效的版本。如果要加中文回退,应该改
|
||||
styles.css 而不是这里(否则被 styles.css 覆盖失效)。 */
|
||||
html, body {
|
||||
margin: 0; padding: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
overflow: hidden; height: 100vh;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
</style>
|
||||
<link rel="stylesheet" href="./styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root">
|
||||
<button id="close" title="关闭浮窗" aria-label="关闭浮窗">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true">
|
||||
<path d="M4 4l8 8M12 4l-8 8" />
|
||||
</svg>
|
||||
</button>
|
||||
<div id="time"><span class="hh">--</span><span class="colon">:</span><span class="mm">--</span><span class="colon">:</span><span class="sec">--</span></div>
|
||||
<div id="date">—</div>
|
||||
</div>
|
||||
<script type="module" src="./main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
67
src/renderer/floating-clock/main.ts
Normal file
67
src/renderer/floating-clock/main.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
// 浮窗时钟:紧凑卡片,只读显示本地时间 + 日期。
|
||||
// - 拖拽走 OS(#root 的 -webkit-app-region: drag)
|
||||
// - 关闭路径:×按钮 / 双击 / Esc / 右键菜单 / 触屏长按
|
||||
|
||||
import { t } from '../i18n';
|
||||
import { attachFloatingWindowBehavior, subscribeFloatingTheme } from '../floating-shared';
|
||||
import { localDateKey } from '../secondTick';
|
||||
|
||||
const root = document.getElementById('root')!;
|
||||
const time = document.querySelector<HTMLElement>('#time')!;
|
||||
const timeHH = time.querySelector<HTMLElement>('.hh')!;
|
||||
const timeMM = time.querySelector<HTMLElement>('.mm')!;
|
||||
const timeSEC = time.querySelector<HTMLElement>('.sec')!;
|
||||
const date = document.querySelector<HTMLElement>('#date')!;
|
||||
|
||||
// === 时间显示:复用主页的"colon+sec 弱化"视觉,但尺寸小一档(36px vs 56px) ===
|
||||
function paintTime(now: string): void {
|
||||
const parts = now.split(':');
|
||||
timeHH.textContent = parts[0] ?? '--';
|
||||
timeMM.textContent = parts[1] ?? '--';
|
||||
timeSEC.textContent = parts[2] ?? '--';
|
||||
}
|
||||
|
||||
// === 日期:星期X M月D日。每分钟对一次,避免日切时没刷新。i18n 走 t() 以便后续扩展 locale。 ===
|
||||
const weekdayStr = t('floating.clock.weekdayChars'); // '日一二三四五六'
|
||||
function paintDate(d: Date): void {
|
||||
const w = weekdayStr[d.getDay()] ?? '';
|
||||
const dateText = t('floating.clock.dateFmt', String(w), String(d.getMonth() + 1), String(d.getDate()));
|
||||
if (date.textContent !== dateText) date.textContent = dateText;
|
||||
}
|
||||
|
||||
// 首屏:用本地时间填值(避免 IPC 异步返回前的占位符闪烁),推送到了再覆盖。
|
||||
const now0 = new Date();
|
||||
paintTime(
|
||||
`${String(now0.getHours()).padStart(2, '0')}:${String(now0.getMinutes()).padStart(2, '0')}:${String(now0.getSeconds()).padStart(2, '0')}`
|
||||
);
|
||||
paintDate(now0);
|
||||
|
||||
// 共用:右键菜单 / 双击关闭 / 触屏长按 / Esc / × 按钮
|
||||
attachFloatingWindowBehavior(root, 'clock');
|
||||
|
||||
// 主题订阅(与 pomodoro / countdown / alarms 共用同一份实现)
|
||||
const unsubTheme = subscribeFloatingTheme();
|
||||
|
||||
// 所有 on.* 订阅统一收口,beforeunload 一次性释放 —— 与浮窗模板对齐。
|
||||
const unsubs: Array<() => void> = [
|
||||
unsubTheme,
|
||||
window.api.on.clockTick((now) => {
|
||||
paintTime(now);
|
||||
})
|
||||
];
|
||||
|
||||
// 跨天后立即刷新(最坏延迟 1 分钟 → 立刻)。lastDayKey 在挂载时即初始化,
|
||||
// 避免首个 30s tick 总是无意义地"发现变化 → 重画"。
|
||||
let lastDayKey = localDateKey(now0);
|
||||
const dayTimer = window.setInterval(() => {
|
||||
const key = localDateKey();
|
||||
if (key !== lastDayKey) {
|
||||
lastDayKey = key;
|
||||
paintDate(new Date());
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
window.addEventListener('beforeunload', () => {
|
||||
window.clearInterval(dayTimer);
|
||||
unsubs.forEach(fn => fn());
|
||||
});
|
||||
93
src/renderer/floating-clock/styles.css
Normal file
93
src/renderer/floating-clock/styles.css
Normal file
@@ -0,0 +1,93 @@
|
||||
/* Floating clock — 200×80 read-only card */
|
||||
html, body {
|
||||
margin: 0; padding: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-family: Inter, system-ui, sans-serif;
|
||||
overflow: hidden; height: 100vh;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
#root {
|
||||
position: relative;
|
||||
width: 200px; height: 80px;
|
||||
margin: 0;
|
||||
background: var(--bg-elev-2);
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--line);
|
||||
box-shadow: var(--shadow-2);
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
-webkit-app-region: drag;
|
||||
display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center;
|
||||
gap: 4px;
|
||||
}
|
||||
#root:active { cursor: grabbing; }
|
||||
|
||||
#close {
|
||||
position: absolute;
|
||||
top: 6px; right: 6px;
|
||||
width: 18px; height: 18px;
|
||||
display: grid; place-items: center;
|
||||
background: var(--line);
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
-webkit-app-region: no-drag;
|
||||
opacity: 0;
|
||||
transition: opacity 120ms, background 120ms, color 120ms, transform 80ms;
|
||||
}
|
||||
#close svg { width: 9px; height: 9px; display: block; }
|
||||
#root:hover #close { opacity: 1; }
|
||||
#close:hover { background: var(--warn-soft); color: var(--warn); }
|
||||
#close:active { transform: scale(0.9); }
|
||||
#root:focus-within #close { opacity: 1; }
|
||||
#close:focus-visible { opacity: 1; outline: 2px solid var(--accent-line); outline-offset: 2px; }
|
||||
@media (hover: none) {
|
||||
#close { opacity: 1; }
|
||||
}
|
||||
|
||||
#time {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-weight: 600;
|
||||
font-size: 36px;
|
||||
color: var(--text);
|
||||
font-feature-settings: "tnum" 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: -1px;
|
||||
line-height: 1;
|
||||
}
|
||||
#time .colon {
|
||||
color: var(--accent);
|
||||
padding: 0 1px;
|
||||
font-weight: 700;
|
||||
animation: clockColonBlink 1s steps(1, end) infinite;
|
||||
}
|
||||
@keyframes clockColonBlink {
|
||||
0%, 50% { opacity: 1; }
|
||||
51%, 100% { opacity: 0.45; }
|
||||
}
|
||||
#time .sec {
|
||||
color: var(--text-dim);
|
||||
font-size: 22px;
|
||||
padding-left: 2px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
#date {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-faint);
|
||||
letter-spacing: 0.04em;
|
||||
/* 不抢主时间的视觉重心 */
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
#time .colon { animation: none; }
|
||||
}
|
||||
165
src/renderer/floating-countdown/index.html
Normal file
165
src/renderer/floating-countdown/index.html
Normal file
@@ -0,0 +1,165 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; media-src 'self' file:" />
|
||||
<title>倒计时</title>
|
||||
<style>
|
||||
/* === 浮窗专用 CSS 变量(6 皮肤,每皮肤只声明浮窗实际用到的 16 个) ===
|
||||
原先加载 global.css + themes.css 合并产物 ~75 KB,实际只用 ~5%。
|
||||
内联后 0 round-trip,且不依赖主进程预创建后的额外资源请求。 */
|
||||
:root {
|
||||
--bg-elev-2: #13141A;
|
||||
--bg-elev-3: #1A1B22;
|
||||
--line: rgba(255, 255, 255, 0.06);
|
||||
--text: #F2F2F5;
|
||||
--text-dim: #B4B4BC;
|
||||
--text-faint: #9090A0;
|
||||
--accent: #5E6AD2;
|
||||
--accent-soft: rgba(94, 106, 210, 0.14);
|
||||
--accent-line: rgba(94, 106, 210, 0.40);
|
||||
--accent-glow: rgba(94, 106, 210, 0.20);
|
||||
--accent-2: #7C8AE5;
|
||||
--warn: #D9695E;
|
||||
--warn-soft: rgba(217, 105, 94, 0.12);
|
||||
--warn-line: rgba(217, 105, 94, 0.40);
|
||||
--ok: #4CB782;
|
||||
--ok-soft: rgba(76, 183, 130, 0.14);
|
||||
--ok-line: rgba(76, 183, 130, 0.40);
|
||||
--shadow-2: 0 12px 28px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
:root[data-skin="brass-dark"] {
|
||||
--bg-elev-2: #2B231B;
|
||||
--bg-elev-3: #38291E;
|
||||
--line: rgba(255, 235, 200, 0.08);
|
||||
--text: #F1E4C7;
|
||||
--text-dim: #C9B998;
|
||||
--text-faint: #8A7E66;
|
||||
--accent: #D9B25F;
|
||||
--accent-soft: rgba(217, 178, 95, 0.14);
|
||||
--accent-line: rgba(217, 178, 95, 0.45);
|
||||
--accent-glow: rgba(217, 178, 95, 0.22);
|
||||
--accent-2: #E8C77A;
|
||||
--warn: #C97050;
|
||||
--warn-soft: rgba(201, 112, 80, 0.14);
|
||||
--warn-line: rgba(201, 112, 80, 0.45);
|
||||
--ok: #7BA47B;
|
||||
--ok-soft: rgba(123, 164, 123, 0.14);
|
||||
--ok-line: rgba(123, 164, 123, 0.45);
|
||||
--shadow-2: 0 12px 28px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
:root[data-skin="solarized-dark"] {
|
||||
--bg-elev-2: #0A4150;
|
||||
--bg-elev-3: #134E5E;
|
||||
--line: rgba(147, 161, 161, 0.20);
|
||||
--text: #EEE8D5;
|
||||
--text-dim: #93A1A1;
|
||||
--text-faint: #657B83;
|
||||
--accent: #268BD2;
|
||||
--accent-soft: rgba(38, 139, 210, 0.18);
|
||||
--accent-line: rgba(38, 139, 210, 0.45);
|
||||
--accent-glow: rgba(38, 139, 210, 0.22);
|
||||
--accent-2: #B58900;
|
||||
--warn: #DC322F;
|
||||
--warn-soft: rgba(220, 50, 47, 0.18);
|
||||
--warn-line: rgba(220, 50, 47, 0.45);
|
||||
--ok: #859900;
|
||||
--ok-soft: rgba(133, 153, 0, 0.18);
|
||||
--ok-line: rgba(133, 153, 0, 0.45);
|
||||
--shadow-2: 0 12px 28px rgba(0, 0, 0, 0.50);
|
||||
}
|
||||
:root[data-skin="nord"] {
|
||||
--bg-elev-2: #434C5E;
|
||||
--bg-elev-3: #4C566A;
|
||||
--line: rgba(229, 233, 240, 0.10);
|
||||
--text: #ECEFF4;
|
||||
--text-dim: #D8DEE9;
|
||||
--text-faint: #88909D;
|
||||
--accent: #88C0D0;
|
||||
--accent-soft: rgba(136, 192, 208, 0.18);
|
||||
--accent-line: rgba(136, 192, 208, 0.45);
|
||||
--accent-glow: rgba(136, 192, 208, 0.22);
|
||||
--accent-2: #81A1C1;
|
||||
--warn: #BF616A;
|
||||
--warn-soft: rgba(191, 97, 106, 0.18);
|
||||
--warn-line: rgba(191, 97, 106, 0.45);
|
||||
--ok: #A3BE8C;
|
||||
--ok-soft: rgba(163, 190, 140, 0.18);
|
||||
--ok-line: rgba(163, 190, 140, 0.45);
|
||||
--shadow-2: 0 12px 28px rgba(0, 0, 0, 0.50);
|
||||
}
|
||||
:root[data-skin="catppuccin"] {
|
||||
--bg-elev-2: #313244;
|
||||
--bg-elev-3: #45475A;
|
||||
--line: rgba(205, 214, 244, 0.10);
|
||||
--text: #CDD6F4;
|
||||
--text-dim: #BAC2DE;
|
||||
--text-faint: #6C7086;
|
||||
--accent: #CBA6F7;
|
||||
--accent-soft: rgba(203, 166, 247, 0.18);
|
||||
--accent-line: rgba(203, 166, 247, 0.45);
|
||||
--accent-glow: rgba(203, 166, 247, 0.22);
|
||||
--accent-2: #F38BA8;
|
||||
--warn: #F38BA8;
|
||||
--warn-soft: rgba(243, 139, 168, 0.18);
|
||||
--warn-line: rgba(243, 139, 168, 0.45);
|
||||
--ok: #A6E3A1;
|
||||
--ok-soft: rgba(166, 227, 161, 0.18);
|
||||
--ok-line: rgba(166, 227, 161, 0.45);
|
||||
--shadow-2: 0 12px 28px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
:root[data-skin="vercel-light"] {
|
||||
--bg-elev-2: #F4F4F5;
|
||||
--bg-elev-3: #E4E4E7;
|
||||
--line: rgba(20, 22, 38, 0.08);
|
||||
--text: #18181B;
|
||||
--text-dim: #50525A;
|
||||
--text-faint: #A1A1AA;
|
||||
--accent: #5E6AD2;
|
||||
--accent-soft: rgba(94, 106, 210, 0.10);
|
||||
--accent-line: rgba(94, 106, 210, 0.40);
|
||||
--accent-glow: rgba(94, 106, 210, 0.18);
|
||||
--accent-2: #6E78C7;
|
||||
--warn: #DC2626;
|
||||
--warn-soft: rgba(220, 38, 38, 0.10);
|
||||
--warn-line: rgba(220, 38, 38, 0.40);
|
||||
--ok: #16A34A;
|
||||
--ok-soft: rgba(22, 163, 74, 0.10);
|
||||
--ok-line: rgba(22, 163, 74, 0.40);
|
||||
--shadow-2: 0 12px 28px rgba(20, 22, 38, 0.10), 0 2px 4px rgba(20, 22, 38, 0.06);
|
||||
}
|
||||
/* 兼容旧 [data-theme] 写法(无 [data-skin])—— 落到 linear-dark 默认值 */
|
||||
:root[data-theme]:not([data-skin]) {
|
||||
--bg-elev-2: #13141A;
|
||||
--bg-elev-3: #1A1B22;
|
||||
}
|
||||
/* === Minimal reset: 浮窗专用精简版 ===
|
||||
background: transparent 是关键 —— 否则覆盖不住桌布,卡片会变黑底。
|
||||
font-family 不在这里写:会被各 floating-*/styles.css 的 html, body 块覆盖,
|
||||
而 styles.css 的 font-family 才是当前生效的版本。如果要加中文回退,应该改
|
||||
styles.css 而不是这里(否则被 styles.css 覆盖失效)。 */
|
||||
html, body {
|
||||
margin: 0; padding: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
overflow: hidden; height: 100vh;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
</style>
|
||||
<link rel="stylesheet" href="./styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root">
|
||||
<button id="close" title="关闭浮窗" aria-label="关闭浮窗">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true">
|
||||
<path d="M4 4l8 8M12 4l-8 8" />
|
||||
</svg>
|
||||
</button>
|
||||
<div id="time"><span class="hh">--</span><span class="colon">:</span><span class="mm">--</span><span class="colon">:</span><span class="sec">--</span></div>
|
||||
<div id="state">空闲</div>
|
||||
</div>
|
||||
<script type="module" src="./main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
104
src/renderer/floating-countdown/main.ts
Normal file
104
src/renderer/floating-countdown/main.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
// 浮窗倒计时:200×120 紧凑卡片,展示剩余时间 + 状态。
|
||||
// - 拖拽走 OS(#root 的 -webkit-app-region: drag)
|
||||
// - 关闭路径与浮窗番茄钟一致:×/双击/Esc/右键/触屏长按
|
||||
// - 状态色边框表达 running / paused / idle / fired(沿用主页 CountdownPanel 的 data-state 约定)
|
||||
|
||||
import { formatHMS } from '../../shared/time';
|
||||
import { t } from '../i18n';
|
||||
import { attachFloatingWindowBehavior, subscribeFloatingTheme } from '../floating-shared';
|
||||
import { createSecondTicker, nextCeilBoundary } from '../secondTick';
|
||||
import type { CountdownActiveState } from '../../shared/types';
|
||||
|
||||
const root = document.getElementById('root')!;
|
||||
const time = document.querySelector<HTMLElement>('#time')!;
|
||||
const timeHH = time.querySelector<HTMLElement>('.hh')!;
|
||||
const timeMM = time.querySelector<HTMLElement>('.mm')!;
|
||||
const timeSEC = time.querySelector<HTMLElement>('.sec')!;
|
||||
const state = document.querySelector<HTMLElement>('#state')!;
|
||||
|
||||
let active: CountdownActiveState | null = null;
|
||||
let lastDurationMs = 5 * 60 * 1000;
|
||||
// IIFE 拿一次 getState() 之后就不再被盖回去 — 理由同 floating-pomodoro:
|
||||
// countdownPhaseChanged 推过来的 active 是权威新值,getState() 返回的
|
||||
// state.current 有 300ms 防抖 + writeAtomic 延迟,期间拿到旧值会反向覆盖。
|
||||
let bootstrapped = false;
|
||||
|
||||
// 睡到读数下一次改变即可(改造前是 250ms 轮询 = 4Hz)。
|
||||
// idle(显示上次设定时长)与 paused 下读数不会自己变,nextAt 返回 null,不装定时器。
|
||||
const ticker = createSecondTicker(paint, () =>
|
||||
!active || active.paused ? null : nextCeilBoundary(active.endsAt)
|
||||
);
|
||||
|
||||
// 同步首屏 paint:active=null 走 idle 分支,显示 lastDurationMs 默认 5:00 / 状态"空闲",
|
||||
// 避免 IPC roundtrip 期间用户看到 HTML 默认占位符 "__:__:__"。
|
||||
paint();
|
||||
|
||||
// 共用:右键菜单 / 双击关闭 / 触屏长按 / Esc / × 按钮
|
||||
attachFloatingWindowBehavior(root, 'countdown');
|
||||
|
||||
// 主题订阅(与 pomodoro / clock / alarms 共用同一份实现)
|
||||
const unsubTheme = subscribeFloatingTheme();
|
||||
|
||||
// 所有 on.* 订阅统一收口,beforeunload 一次性释放 —— 与浮窗模板对齐。
|
||||
const unsubs: Array<() => void> = [
|
||||
unsubTheme,
|
||||
window.api.on.countdownPhaseChanged((next) => {
|
||||
bootstrapped = true;
|
||||
active = next;
|
||||
paint();
|
||||
ticker.restart();
|
||||
}),
|
||||
window.api.on.storageChanged((s) => {
|
||||
// lastDurationMs 由 storageChanged 推送(输入框改值时),保持同步
|
||||
lastDurationMs = s.countdown.lastDurationMs;
|
||||
paint();
|
||||
})
|
||||
];
|
||||
|
||||
(async () => {
|
||||
const s = await window.api.clock.getState();
|
||||
if (bootstrapped) return;
|
||||
active = s.countdown.active;
|
||||
lastDurationMs = s.countdown.lastDurationMs;
|
||||
paint();
|
||||
ticker.restart();
|
||||
})();
|
||||
|
||||
function paint(): void {
|
||||
if (!active) {
|
||||
// idle:显示上次设定时长
|
||||
paintTime(Math.ceil(lastDurationMs / 1000));
|
||||
state.textContent = t('floating.cd.stateIdle');
|
||||
root.dataset.state = 'idle';
|
||||
return;
|
||||
}
|
||||
const remain = active.paused
|
||||
? active.remainingMs
|
||||
: Math.max(0, active.endsAt - Date.now());
|
||||
paintTime(Math.ceil(remain / 1000));
|
||||
if (active.paused) {
|
||||
state.textContent = t('floating.cd.statePaused');
|
||||
root.dataset.state = 'paused';
|
||||
} else {
|
||||
state.textContent = t('floating.cd.stateRunning');
|
||||
root.dataset.state = 'running';
|
||||
}
|
||||
}
|
||||
|
||||
// 直接写到预生成 span 上,避免 innerHTML 反复解析。
|
||||
// 冒号单独挂在 <span class="colon"> 上以染 accent;这里只刷 hh / mm / sec 的纯数字。
|
||||
function paintTime(totalSec: number): void {
|
||||
const formatted = formatHMS(Math.max(0, totalSec));
|
||||
const parts = formatted.split(':');
|
||||
const hh = parts[0] ?? '--';
|
||||
const mm = parts[1] ?? '--';
|
||||
const ss = parts[2] ?? '--';
|
||||
timeHH.textContent = hh;
|
||||
timeMM.textContent = mm;
|
||||
timeSEC.textContent = ss;
|
||||
}
|
||||
|
||||
window.addEventListener('beforeunload', () => {
|
||||
ticker.stop();
|
||||
unsubs.forEach(fn => fn());
|
||||
});
|
||||
113
src/renderer/floating-countdown/styles.css
Normal file
113
src/renderer/floating-countdown/styles.css
Normal file
@@ -0,0 +1,113 @@
|
||||
/* Floating countdown — 200×120 card showing remaining time + state */
|
||||
html, body {
|
||||
margin: 0; padding: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-family: Inter, system-ui, sans-serif;
|
||||
overflow: hidden; height: 100vh;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
#root {
|
||||
position: relative;
|
||||
width: 200px; height: 120px;
|
||||
margin: 0;
|
||||
background: var(--bg-elev-2);
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--line);
|
||||
box-shadow: var(--shadow-2);
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
-webkit-app-region: drag;
|
||||
display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center;
|
||||
gap: 6px;
|
||||
/* 状态色边框 + 柔光表达 running/paused/idle */
|
||||
transition: border-color 160ms, box-shadow 160ms;
|
||||
}
|
||||
#root[data-state="running"] {
|
||||
border-color: var(--accent-line);
|
||||
box-shadow: var(--shadow-2), 0 0 0 1px var(--accent-line), 0 0 14px var(--accent-glow);
|
||||
}
|
||||
#root[data-state="paused"] {
|
||||
border-color: rgba(217, 105, 94, 0.50);
|
||||
box-shadow: var(--shadow-2), 0 0 0 1px rgba(217, 105, 94, 0.50);
|
||||
}
|
||||
/* idle 不加额外色,保持空态克制 */
|
||||
#root[data-state="idle"] {
|
||||
border-color: var(--line);
|
||||
opacity: 0.92;
|
||||
}
|
||||
#root:active { cursor: grabbing; }
|
||||
|
||||
#close {
|
||||
position: absolute;
|
||||
top: 6px; right: 6px;
|
||||
width: 18px; height: 18px;
|
||||
display: grid; place-items: center;
|
||||
background: var(--line);
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
-webkit-app-region: no-drag;
|
||||
opacity: 0;
|
||||
transition: opacity 120ms, background 120ms, color 120ms, transform 80ms;
|
||||
}
|
||||
#close svg { width: 9px; height: 9px; display: block; }
|
||||
#root:hover #close { opacity: 1; }
|
||||
#close:hover { background: var(--warn-soft); color: var(--warn); }
|
||||
#close:active { transform: scale(0.9); }
|
||||
#root:focus-within #close { opacity: 1; }
|
||||
#close:focus-visible { opacity: 1; outline: 2px solid var(--accent-line); outline-offset: 2px; }
|
||||
@media (hover: none) {
|
||||
#close { opacity: 1; }
|
||||
}
|
||||
|
||||
#time {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-weight: 600;
|
||||
font-size: 36px;
|
||||
color: var(--text);
|
||||
font-feature-settings: "tnum" 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: -1px;
|
||||
line-height: 1;
|
||||
}
|
||||
#time .colon {
|
||||
color: var(--accent);
|
||||
padding: 0 1px;
|
||||
font-weight: 700;
|
||||
}
|
||||
#root[data-state="paused"] #time .colon { color: var(--warn); }
|
||||
#time .sec {
|
||||
color: var(--text-dim);
|
||||
font-size: 22px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
#state {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: var(--text-dim);
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
padding: 3px 10px;
|
||||
border-radius: 99px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(14, 14, 18, 0.4);
|
||||
transition: color 120ms, border-color 120ms, background 120ms;
|
||||
}
|
||||
#root[data-state="running"] #state {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent-line);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
#root[data-state="paused"] #state {
|
||||
color: var(--warn);
|
||||
border-color: rgba(217, 105, 94, 0.40);
|
||||
background: var(--warn-soft);
|
||||
}
|
||||
179
src/renderer/floating-pomodoro/index.html
Normal file
179
src/renderer/floating-pomodoro/index.html
Normal file
@@ -0,0 +1,179 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; media-src 'self' file:" />
|
||||
<title>番茄钟</title>
|
||||
<style>
|
||||
/* === 浮窗专用 CSS 变量(6 皮肤,每皮肤只声明浮窗实际用到的 16 个) ===
|
||||
原先加载 global.css + themes.css 合并产物 ~75 KB,实际只用 ~5%。
|
||||
内联后 0 round-trip,且不依赖主进程预创建后的额外资源请求。 */
|
||||
:root {
|
||||
--bg-elev-2: #13141A;
|
||||
--bg-elev-3: #1A1B22;
|
||||
--line: rgba(255, 255, 255, 0.06);
|
||||
--text: #F2F2F5;
|
||||
--text-dim: #B4B4BC;
|
||||
--text-faint: #9090A0;
|
||||
--accent: #5E6AD2;
|
||||
--accent-soft: rgba(94, 106, 210, 0.14);
|
||||
--accent-line: rgba(94, 106, 210, 0.40);
|
||||
--accent-glow: rgba(94, 106, 210, 0.20);
|
||||
--accent-2: #7C8AE5;
|
||||
--warn: #D9695E;
|
||||
--warn-soft: rgba(217, 105, 94, 0.12);
|
||||
--warn-line: rgba(217, 105, 94, 0.40);
|
||||
--ok: #4CB782;
|
||||
--ok-soft: rgba(76, 183, 130, 0.14);
|
||||
--ok-line: rgba(76, 183, 130, 0.40);
|
||||
--shadow-2: 0 12px 28px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
:root[data-skin="brass-dark"] {
|
||||
--bg-elev-2: #2B231B;
|
||||
--bg-elev-3: #38291E;
|
||||
--line: rgba(255, 235, 200, 0.08);
|
||||
--text: #F1E4C7;
|
||||
--text-dim: #C9B998;
|
||||
--text-faint: #8A7E66;
|
||||
--accent: #D9B25F;
|
||||
--accent-soft: rgba(217, 178, 95, 0.14);
|
||||
--accent-line: rgba(217, 178, 95, 0.45);
|
||||
--accent-glow: rgba(217, 178, 95, 0.22);
|
||||
--accent-2: #E8C77A;
|
||||
--warn: #C97050;
|
||||
--warn-soft: rgba(201, 112, 80, 0.14);
|
||||
--warn-line: rgba(201, 112, 80, 0.45);
|
||||
--ok: #7BA47B;
|
||||
--ok-soft: rgba(123, 164, 123, 0.14);
|
||||
--ok-line: rgba(123, 164, 123, 0.45);
|
||||
--shadow-2: 0 12px 28px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
:root[data-skin="solarized-dark"] {
|
||||
--bg-elev-2: #0A4150;
|
||||
--bg-elev-3: #134E5E;
|
||||
--line: rgba(147, 161, 161, 0.20);
|
||||
--text: #EEE8D5;
|
||||
--text-dim: #93A1A1;
|
||||
--text-faint: #657B83;
|
||||
--accent: #268BD2;
|
||||
--accent-soft: rgba(38, 139, 210, 0.18);
|
||||
--accent-line: rgba(38, 139, 210, 0.45);
|
||||
--accent-glow: rgba(38, 139, 210, 0.22);
|
||||
--accent-2: #B58900;
|
||||
--warn: #DC322F;
|
||||
--warn-soft: rgba(220, 50, 47, 0.18);
|
||||
--warn-line: rgba(220, 50, 47, 0.45);
|
||||
--ok: #859900;
|
||||
--ok-soft: rgba(133, 153, 0, 0.18);
|
||||
--ok-line: rgba(133, 153, 0, 0.45);
|
||||
--shadow-2: 0 12px 28px rgba(0, 0, 0, 0.50);
|
||||
}
|
||||
:root[data-skin="nord"] {
|
||||
--bg-elev-2: #434C5E;
|
||||
--bg-elev-3: #4C566A;
|
||||
--line: rgba(229, 233, 240, 0.10);
|
||||
--text: #ECEFF4;
|
||||
--text-dim: #D8DEE9;
|
||||
--text-faint: #88909D;
|
||||
--accent: #88C0D0;
|
||||
--accent-soft: rgba(136, 192, 208, 0.18);
|
||||
--accent-line: rgba(136, 192, 208, 0.45);
|
||||
--accent-glow: rgba(136, 192, 208, 0.22);
|
||||
--accent-2: #81A1C1;
|
||||
--warn: #BF616A;
|
||||
--warn-soft: rgba(191, 97, 106, 0.18);
|
||||
--warn-line: rgba(191, 97, 106, 0.45);
|
||||
--ok: #A3BE8C;
|
||||
--ok-soft: rgba(163, 190, 140, 0.18);
|
||||
--ok-line: rgba(163, 190, 140, 0.45);
|
||||
--shadow-2: 0 12px 28px rgba(0, 0, 0, 0.50);
|
||||
}
|
||||
:root[data-skin="catppuccin"] {
|
||||
--bg-elev-2: #313244;
|
||||
--bg-elev-3: #45475A;
|
||||
--line: rgba(205, 214, 244, 0.10);
|
||||
--text: #CDD6F4;
|
||||
--text-dim: #BAC2DE;
|
||||
--text-faint: #6C7086;
|
||||
--accent: #CBA6F7;
|
||||
--accent-soft: rgba(203, 166, 247, 0.18);
|
||||
--accent-line: rgba(203, 166, 247, 0.45);
|
||||
--accent-glow: rgba(203, 166, 247, 0.22);
|
||||
--accent-2: #F38BA8;
|
||||
--warn: #F38BA8;
|
||||
--warn-soft: rgba(243, 139, 168, 0.18);
|
||||
--warn-line: rgba(243, 139, 168, 0.45);
|
||||
--ok: #A6E3A1;
|
||||
--ok-soft: rgba(166, 227, 161, 0.18);
|
||||
--ok-line: rgba(166, 227, 161, 0.45);
|
||||
--shadow-2: 0 12px 28px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
:root[data-skin="vercel-light"] {
|
||||
--bg-elev-2: #F4F4F5;
|
||||
--bg-elev-3: #E4E4E7;
|
||||
--line: rgba(20, 22, 38, 0.08);
|
||||
--text: #18181B;
|
||||
--text-dim: #50525A;
|
||||
--text-faint: #A1A1AA;
|
||||
--accent: #5E6AD2;
|
||||
--accent-soft: rgba(94, 106, 210, 0.10);
|
||||
--accent-line: rgba(94, 106, 210, 0.40);
|
||||
--accent-glow: rgba(94, 106, 210, 0.18);
|
||||
--accent-2: #6E78C7;
|
||||
--warn: #DC2626;
|
||||
--warn-soft: rgba(220, 38, 38, 0.10);
|
||||
--warn-line: rgba(220, 38, 38, 0.40);
|
||||
--ok: #16A34A;
|
||||
--ok-soft: rgba(22, 163, 74, 0.10);
|
||||
--ok-line: rgba(22, 163, 74, 0.40);
|
||||
--shadow-2: 0 12px 28px rgba(20, 22, 38, 0.10), 0 2px 4px rgba(20, 22, 38, 0.06);
|
||||
}
|
||||
/* 兼容旧 [data-theme] 写法(无 [data-skin])—— 落到 linear-dark 默认值 */
|
||||
:root[data-theme]:not([data-skin]) {
|
||||
--bg-elev-2: #13141A;
|
||||
--bg-elev-3: #1A1B22;
|
||||
}
|
||||
/* === Minimal reset: 浮窗专用精简版 ===
|
||||
background: transparent 是关键 —— 否则覆盖不住桌布,卡片会变黑底。
|
||||
font-family 不在这里写:会被各 floating-*/styles.css 的 html, body 块覆盖,
|
||||
而 styles.css 的 font-family 才是当前生效的版本。如果要加中文回退,应该改
|
||||
styles.css 而不是这里(否则被 styles.css 覆盖失效)。 */
|
||||
html, body {
|
||||
margin: 0; padding: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
overflow: hidden; height: 100vh;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
</style>
|
||||
<link rel="stylesheet" href="./styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root">
|
||||
<button id="close" title="关闭浮窗" aria-label="关闭浮窗">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true">
|
||||
<path d="M4 4l8 8M12 4l-8 8" />
|
||||
</svg>
|
||||
</button>
|
||||
<div id="phase" aria-hidden="true"></div>
|
||||
<div id="time"><span class="mm">--</span><span class="colon">:</span><span class="sec">--</span></div>
|
||||
<div id="actions" role="group" aria-label="番茄钟控制">
|
||||
<button id="playPause" class="control" title="开始 / 暂停" aria-label="开始或暂停番茄钟" type="button">
|
||||
<svg id="playPauseIcon" viewBox="0 0 16 16" fill="currentColor" stroke="none" aria-hidden="true" data-icon="play">
|
||||
<path class="icon-play" d="M4.5 3.5v9l8-4.5z" />
|
||||
<path class="icon-pause" d="M5 3.5h2v9H5zM9 3.5h2v9H9z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button id="reset" class="control" title="重置" aria-label="重置番茄钟" type="button">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M3 8a5 5 0 1 0 1.5-3.5L3 6" />
|
||||
<path d="M3 3v3h3" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="./main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
139
src/renderer/floating-pomodoro/main.ts
Normal file
139
src/renderer/floating-pomodoro/main.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { formatHMS } from '../../shared/time';
|
||||
import { t } from '../i18n';
|
||||
import { attachFloatingWindowBehavior, subscribeFloatingTheme } from '../floating-shared';
|
||||
import { createSecondTicker, nextCeilBoundary } from '../secondTick';
|
||||
import type { PomodoroPhase } from '../../shared/types';
|
||||
|
||||
const root = document.getElementById('root')!;
|
||||
const time = document.querySelector<HTMLElement>('#time')!;
|
||||
const timeMM = time.querySelector<HTMLElement>('.mm')!;
|
||||
const timeSEC = time.querySelector<HTMLElement>('.sec')!;
|
||||
const phase = document.querySelector<HTMLElement>('#phase')!;
|
||||
const actions = document.querySelector<HTMLElement>('#actions')!;
|
||||
const playPauseBtn = document.querySelector<HTMLButtonElement>('#playPause')!;
|
||||
const playPauseIcon = document.querySelector<SVGElement>('#playPauseIcon')!;
|
||||
const resetBtn = document.querySelector<HTMLButtonElement>('#reset')!;
|
||||
|
||||
// 拖拽完全交给 OS —— #root 的 -webkit-app-region: drag 已经接管。
|
||||
// 早期这里还绑了一份 mousedown/mousemove 会让窗口跑 2 倍距离(OS 移一次,
|
||||
// 我们又算 dx/dy 移一次),已移除。
|
||||
|
||||
// === 状态显示 ===
|
||||
type ActiveLite = { phase: PomodoroPhase; endsAt: number; paused: boolean; remainingMs: number };
|
||||
let active: ActiveLite | null = null;
|
||||
// pomodoroPhaseChanged 已经覆盖过一次之后,初始 getState() 不能再盖回去:
|
||||
// getState() 返回的是 state.current,而 state.current 要等 300ms 防抖 + writeAtomic
|
||||
// 之后才更新(参 AppStateContainer.update)。期间 getState() 仍是旧值,会覆盖掉
|
||||
// 真正的 onPhaseChange IPC 推过来的新 active —— 表现为"已经收到变化,又被旧值拉回"。
|
||||
// 正常场景下二者最终一致,只在启动瞬间连点 start / 重启留有 active 时才暴露。
|
||||
let bootstrapped = false;
|
||||
|
||||
// 睡到读数下一次改变即可(改造前是 250ms 轮询 = 4Hz)。
|
||||
// 暂停 / 未开始时读数不会自己变,nextAt 返回 null,完全不装定时器。
|
||||
const ticker = createSecondTicker(tick, () =>
|
||||
!active || active.paused ? null : nextCeilBoundary(active.endsAt)
|
||||
);
|
||||
|
||||
|
||||
(async () => {
|
||||
const s = await window.api.clock.getState();
|
||||
if (bootstrapped) return;
|
||||
active = s.pomodoro.active ?? null;
|
||||
tick();
|
||||
ticker.restart();
|
||||
})();
|
||||
|
||||
// 共用:右键菜单 / 双击关闭 / 触屏长按 / Esc / × 按钮(行为见 floating-shared.ts)
|
||||
attachFloatingWindowBehavior(root, 'pomodoro');
|
||||
|
||||
// 主题订阅:浮窗没有完整 store 子集,单独拉 settings.themeId 同步到 <html data-skin>。
|
||||
// 切换主题后立即刷新,无延迟;unsub 在 beforeunload 一并释放。
|
||||
const unsubTheme = subscribeFloatingTheme();
|
||||
|
||||
// 所有 on.* 订阅统一收口,beforeunload 一次性释放 —— 与浮窗模板对齐。
|
||||
const unsubs: Array<() => void> = [
|
||||
unsubTheme,
|
||||
window.api.on.pomodoroPhaseChanged((next) => {
|
||||
bootstrapped = true;
|
||||
active = next;
|
||||
tick();
|
||||
ticker.restart();
|
||||
})
|
||||
];
|
||||
|
||||
window.addEventListener('beforeunload', () => {
|
||||
ticker.stop();
|
||||
unsubs.forEach(fn => fn());
|
||||
});
|
||||
|
||||
// === 控制按钮:play / pause / reset ===
|
||||
// 一个按钮按当前状态切换:
|
||||
// - 没有 active → 显示"播放",点击 start()
|
||||
// - active && !paused → 显示"暂停",点击 pause()
|
||||
// - active && paused → 显示"播放",点击 resume()
|
||||
function onPlayPauseClick(e: MouseEvent): void {
|
||||
e.stopPropagation();
|
||||
if (!active) {
|
||||
void window.api.pomodoro.start();
|
||||
} else if (active.paused) {
|
||||
void window.api.pomodoro.resume();
|
||||
} else {
|
||||
void window.api.pomodoro.pause();
|
||||
}
|
||||
}
|
||||
playPauseBtn.addEventListener('click', onPlayPauseClick);
|
||||
playPauseBtn.addEventListener('mousedown', (e) => e.stopPropagation());
|
||||
playPauseBtn.addEventListener('pointerdown', (e) => e.stopPropagation());
|
||||
|
||||
// reset:只有存在 active 时才有点击意义;按钮也只在有 active 时可见
|
||||
resetBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
void window.api.pomodoro.reset();
|
||||
});
|
||||
resetBtn.addEventListener('mousedown', (e) => e.stopPropagation());
|
||||
resetBtn.addEventListener('pointerdown', (e) => e.stopPropagation());
|
||||
|
||||
function tick(): void {
|
||||
if (!active) {
|
||||
timeMM.textContent = '--';
|
||||
timeSEC.textContent = '--';
|
||||
phase.textContent = t('pomo.idle');
|
||||
root.dataset.state = 'idle';
|
||||
playPauseIcon.dataset.icon = 'play';
|
||||
playPauseBtn.title = t('pomo.start');
|
||||
playPauseBtn.setAttribute('aria-label', t('pomo.start'));
|
||||
// idle 时只显播放;重置藏在 hover 之外(反正没东西可重置)
|
||||
actions.dataset.state = 'idle';
|
||||
return;
|
||||
}
|
||||
const remain = active.paused
|
||||
? active.remainingMs
|
||||
: Math.max(0, active.endsAt - Date.now());
|
||||
const formatted = formatHMS(Math.ceil(remain / 1000));
|
||||
// 番茄钟最长 25 分钟,HH 永远为 00 — 卡片里只显示 MM:SS,秒弱化。
|
||||
const parts = formatted.split(':');
|
||||
const mm = parts[1] ?? '--';
|
||||
const ss = parts[2] ?? '--';
|
||||
timeMM.textContent = mm;
|
||||
timeSEC.textContent = ss;
|
||||
const label = ({ focus: t('pomo.focus'), shortBreak: t('pomo.shortBreak'), longBreak: t('pomo.longBreak') } as Record<PomodoroPhase, string>)[active.phase] ?? t('pomo.idle');
|
||||
phase.textContent = active.paused ? `${label} · ${t('pomo.paused')}` : label;
|
||||
// data-state 决定浮窗整体配色:paused 警告,break 绿,running 紫
|
||||
if (active.paused) root.dataset.state = 'paused';
|
||||
else if (active.phase !== 'focus') root.dataset.state = 'break';
|
||||
else root.dataset.state = 'running';
|
||||
|
||||
// 播放/暂停图标互换:paused → 播放;running → 暂停。两条 path 都在 DOM 里,
|
||||
// 用 data-icon 切显示,避免每次 innerHTML 重新解析(SVG 在 Chromium 里 innerHTML 很贵)。
|
||||
if (active.paused) {
|
||||
playPauseIcon.dataset.icon = 'play';
|
||||
playPauseBtn.title = t('pomo.resume');
|
||||
playPauseBtn.setAttribute('aria-label', t('pomo.resume'));
|
||||
actions.dataset.state = 'paused';
|
||||
} else {
|
||||
playPauseIcon.dataset.icon = 'pause';
|
||||
playPauseBtn.title = t('pomo.pause');
|
||||
playPauseBtn.setAttribute('aria-label', t('pomo.pause'));
|
||||
actions.dataset.state = 'running';
|
||||
}
|
||||
}
|
||||
137
src/renderer/floating-pomodoro/styles.css
Normal file
137
src/renderer/floating-pomodoro/styles.css
Normal file
@@ -0,0 +1,137 @@
|
||||
/* Floating pomodoro — compact card (120×120) */
|
||||
html, body {
|
||||
margin: 0; padding: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-family: Inter, system-ui, sans-serif;
|
||||
overflow: hidden; height: 100vh;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
#root {
|
||||
position: relative; width: 120px; height: 120px;
|
||||
margin: 0;
|
||||
background: var(--bg-elev-2);
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--line);
|
||||
box-shadow: var(--shadow-2);
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
-webkit-app-region: drag;
|
||||
display: grid; place-items: center;
|
||||
transition: border-color 160ms, box-shadow 160ms;
|
||||
}
|
||||
/* 状态仅通过 1px 边框色 + 柔光表达,去掉重动画与重渐变 */
|
||||
#root[data-state="running"] {
|
||||
border-color: var(--accent-line);
|
||||
box-shadow: var(--shadow-2), 0 0 0 1px var(--accent-line), 0 0 14px var(--accent-glow);
|
||||
}
|
||||
#root[data-state="break"] {
|
||||
border-color: rgba(76, 183, 130, 0.50);
|
||||
box-shadow: var(--shadow-2), 0 0 0 1px rgba(76, 183, 130, 0.50);
|
||||
}
|
||||
#root[data-state="paused"] {
|
||||
border-color: rgba(217, 105, 94, 0.50);
|
||||
box-shadow: var(--shadow-2), 0 0 0 1px rgba(217, 105, 94, 0.50);
|
||||
}
|
||||
#root:active { cursor: grabbing; }
|
||||
|
||||
#close {
|
||||
position: absolute;
|
||||
top: 6px; right: 6px;
|
||||
width: 18px; height: 18px;
|
||||
display: grid; place-items: center;
|
||||
background: var(--line);
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
-webkit-app-region: no-drag;
|
||||
opacity: 0;
|
||||
transition: opacity 120ms, background 120ms, color 120ms, transform 80ms;
|
||||
}
|
||||
#close svg { width: 9px; height: 9px; display: block; }
|
||||
#root:hover #close { opacity: 1; }
|
||||
#close:hover { background: var(--warn-soft); color: var(--warn); }
|
||||
#close:active { transform: scale(0.9); }
|
||||
#root:focus-within #close { opacity: 1; }
|
||||
#close:focus-visible { opacity: 1; outline: 2px solid var(--accent-line); outline-offset: 2px; }
|
||||
/* 触屏:hover 不可达,常驻显示 × */
|
||||
@media (hover: none) {
|
||||
#close { opacity: 1; }
|
||||
}
|
||||
|
||||
/* 控制按钮(播放/暂停 + 重置):底栏居中排布,与 × 一致的 hover 显隐。
|
||||
* idle 状态下重置无意义,单独弱化;hover 时一起显出,方便停掉意外启动的。 */
|
||||
#actions {
|
||||
position: absolute;
|
||||
bottom: 8px; left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex; gap: 6px;
|
||||
opacity: 0;
|
||||
transition: opacity 120ms;
|
||||
pointer-events: none;
|
||||
}
|
||||
#root:hover #actions,
|
||||
#root:focus-within #actions {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
@media (hover: none) {
|
||||
#actions { opacity: 1; pointer-events: auto; }
|
||||
}
|
||||
.control {
|
||||
width: 22px; height: 22px;
|
||||
display: grid; place-items: center;
|
||||
background: var(--line);
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
-webkit-app-region: no-drag;
|
||||
transition: background 120ms, color 120ms, transform 80ms;
|
||||
}
|
||||
.control svg { width: 11px; height: 11px; display: block; }
|
||||
/* play / pause 两个 path 同时存在,按 #playPauseIcon[data-icon] 切显隐,避免 innerHTML 反复解析 */
|
||||
#playPauseIcon .icon-play,
|
||||
#playPauseIcon .icon-pause { display: none; }
|
||||
#playPauseIcon[data-icon="play"] .icon-play { display: inline; }
|
||||
#playPauseIcon[data-icon="pause"] .icon-pause { display: inline; }
|
||||
.control:hover { background: var(--bg-elev-3); color: var(--text); }
|
||||
.control:active { transform: scale(0.9); }
|
||||
.control:focus-visible {
|
||||
outline: 2px solid var(--accent-line);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
/* 暂停色用警告色编码,与 #root[data-state="paused"] 边框色保持一致 */
|
||||
#root[data-state="paused"] #playPause { color: var(--warn); }
|
||||
.control:focus-visible { opacity: 1; }
|
||||
|
||||
#time {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-weight: 600;
|
||||
font-size: 24px;
|
||||
color: var(--text);
|
||||
font-feature-settings: "tnum" 1;
|
||||
letter-spacing: -0.5px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1;
|
||||
}
|
||||
#time .colon { color: var(--accent); padding: 0 1px; font-weight: 700; }
|
||||
#root[data-state="break"] #time .colon { color: var(--ok); }
|
||||
#time .sec { color: var(--text-dim); font-size: 16px; padding-left: 1px; }
|
||||
|
||||
#phase {
|
||||
position: absolute;
|
||||
top: 10px; left: 12px;
|
||||
color: var(--text-dim);
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
transition: color 120ms;
|
||||
}
|
||||
#root[data-state="break"] #phase { color: var(--ok); }
|
||||
#root[data-state="paused"] #phase { color: var(--warn); }
|
||||
#root[data-state="running"] #phase { color: var(--accent); }
|
||||
114
src/renderer/floating-shared.ts
Normal file
114
src/renderer/floating-shared.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
// src/renderer/floating-shared.ts
|
||||
// 4 个浮窗(pomodoro / clock / alarms / countdown)的输入行为是逐字复制的:
|
||||
// 鼠标右键 / 双击 / 触屏长按 / Esc 键 / × 按钮,只有 IPC kind 不同。
|
||||
// 这里把它们收成单一函数,各浮窗 main.ts 调用一次即可,不再复制 ~50 行事件处理。
|
||||
//
|
||||
// 调用方如果有自己的按钮(如 pomodoro 的 play/pause/reset),在自己按钮上
|
||||
// stopPropagation 即可,与这里的 #root 级监听器不冲突。
|
||||
|
||||
import type { AppState, FloatingKind } from '../shared/types';
|
||||
import { DEFAULT_SKIN_ID, modeForSkin, normalizeSkinId } from '../shared/theme';
|
||||
|
||||
/**
|
||||
* 把 AppState.settings.themeId 应用到 <html data-skin>,并写 data-theme。
|
||||
* 浮窗没有完整 store 子集,直接读写 DOM 即可;themes.css 用 [data-skin] /
|
||||
* [data-theme] 选择器覆盖变量。
|
||||
*
|
||||
* - 首屏调用:传入 getState() 的快照,让 CSS 变量先填上,避免用户切皮肤后
|
||||
* 浮窗还停在默认配色。
|
||||
* - 持续同步:监听 storageChanged,在 settings.themeId 变化时再次调用,主题
|
||||
* "立即生效"路径与主窗走同一条 IPC 推送链路,所以一次订阅就足够。
|
||||
*/
|
||||
export function syncFloatingSkin(state: AppState): void {
|
||||
const id = normalizeSkinId(state.settings.themeId ?? DEFAULT_SKIN_ID);
|
||||
document.documentElement.dataset.skin = id;
|
||||
document.documentElement.dataset.theme = modeForSkin(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步浮窗主题 + 订阅主题变化。返回的函数用于 beforeunload 时解绑 listener。
|
||||
* 调用方式:`const unsubTheme = subscribeFloatingTheme(); ... window.addEventListener('beforeunload', unsubTheme);`
|
||||
*/
|
||||
export function subscribeFloatingTheme(): () => void {
|
||||
// 首屏:拉一次 state 把当前主题写到 <html>,免得看着像 Linear 暗色过几秒才切。
|
||||
void window.api.clock.getState().then((s) => syncFloatingSkin(s));
|
||||
// 之后:settings.push 走 storageChanged,主题变更会一并过来。
|
||||
return window.api.on.storageChanged((s) => syncFloatingSkin(s));
|
||||
}
|
||||
|
||||
export function attachFloatingWindowBehavior(root: HTMLElement, kind: FloatingKind): () => void {
|
||||
const api = window.api.windows;
|
||||
|
||||
const onContextMenu = (e: MouseEvent): void => {
|
||||
e.preventDefault();
|
||||
void api.showFloatingContextMenu(kind);
|
||||
};
|
||||
const onDblClick = (e: MouseEvent): void => {
|
||||
e.preventDefault();
|
||||
void api.closeFloating(kind);
|
||||
};
|
||||
|
||||
// 触屏长按:contextmenu 在触屏上不触发,用 setTimeout ~500ms 模拟。
|
||||
let pressTimer: number | null = null;
|
||||
let pressStartX = 0;
|
||||
let pressStartY = 0;
|
||||
const onPointerDown = (e: PointerEvent): void => {
|
||||
if (e.pointerType !== 'touch') return;
|
||||
pressStartX = e.clientX;
|
||||
pressStartY = e.clientY;
|
||||
pressTimer = window.setTimeout(() => {
|
||||
pressTimer = null;
|
||||
void api.showFloatingContextMenu(kind);
|
||||
}, 500);
|
||||
};
|
||||
const cancelPress = (e: PointerEvent): void => {
|
||||
if (e.pointerType !== 'touch') return;
|
||||
// 移动超过 10px 视为"拖动而非长按"
|
||||
if (pressTimer !== null && (Math.abs(e.clientX - pressStartX) > 10 || Math.abs(e.clientY - pressStartY) > 10)) {
|
||||
window.clearTimeout(pressTimer);
|
||||
pressTimer = null;
|
||||
}
|
||||
};
|
||||
const onPointerMove = (e: PointerEvent): void => { cancelPress(e); };
|
||||
const onPointerUp = (): void => {
|
||||
if (pressTimer !== null) { window.clearTimeout(pressTimer); pressTimer = null; }
|
||||
};
|
||||
const onPointerCancel = (): void => {
|
||||
if (pressTimer !== null) { window.clearTimeout(pressTimer); pressTimer = null; }
|
||||
};
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent): void => {
|
||||
if (e.key === 'Escape') void api.closeFloating(kind);
|
||||
};
|
||||
|
||||
root.addEventListener('contextmenu', onContextMenu);
|
||||
root.addEventListener('dblclick', onDblClick);
|
||||
root.addEventListener('pointerdown', onPointerDown);
|
||||
root.addEventListener('pointermove', onPointerMove);
|
||||
root.addEventListener('pointerup', onPointerUp);
|
||||
root.addEventListener('pointercancel', onPointerCancel);
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
|
||||
// × 按钮(可选)。stopPropagation 防止触发 #root 的 drag 起点。
|
||||
const closeBtn = document.getElementById('close');
|
||||
if (closeBtn) {
|
||||
const onCloseClick = (e: MouseEvent): void => {
|
||||
e.stopPropagation();
|
||||
void api.closeFloating(kind);
|
||||
};
|
||||
const stop = (e: Event): void => { e.stopPropagation(); };
|
||||
closeBtn.addEventListener('click', onCloseClick);
|
||||
closeBtn.addEventListener('mousedown', stop);
|
||||
closeBtn.addEventListener('pointerdown', stop);
|
||||
}
|
||||
|
||||
return () => {
|
||||
root.removeEventListener('contextmenu', onContextMenu);
|
||||
root.removeEventListener('dblclick', onDblClick);
|
||||
root.removeEventListener('pointerdown', onPointerDown);
|
||||
root.removeEventListener('pointermove', onPointerMove);
|
||||
root.removeEventListener('pointerup', onPointerUp);
|
||||
root.removeEventListener('pointercancel', onPointerCancel);
|
||||
window.removeEventListener('keydown', onKeyDown);
|
||||
};
|
||||
}
|
||||
171
src/renderer/i18n.ts
Normal file
171
src/renderer/i18n.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
// 极简文案:只有中文一种语言;保持 t() 调用点的 API 不变以便后续扩展。
|
||||
// 缺失键时静默回落到键名本身(开发期容易发现)。
|
||||
// 简单 {0} {1} 占位符替换。
|
||||
const dict = {
|
||||
// 顶栏 / 侧栏
|
||||
'app.brand': 'Time',
|
||||
'nav.clock': '时钟',
|
||||
'nav.alarms': '闹钟',
|
||||
'nav.pomodoro': '番茄钟',
|
||||
'nav.countdown': '倒计时',
|
||||
/** TabStrip 整条 tablist 的无障碍名称 */
|
||||
'nav.mainTabs': '主导航',
|
||||
'topbar.pinOn': '已置顶',
|
||||
'topbar.pinOff': '置顶',
|
||||
'topbar.settings': '设置',
|
||||
'topbar.floatingGroup': '浮窗小卡片',
|
||||
'topbar.floatingPomodoro': '浮窗番茄钟',
|
||||
'topbar.floatingClock': '浮窗时钟',
|
||||
'topbar.floatingAlarms': '浮窗闹钟',
|
||||
'topbar.floatingCountdown': '浮窗倒计时',
|
||||
// 时钟面板
|
||||
'clock.subtitle': '当前时间',
|
||||
'clock.nextNone': '暂无闹钟',
|
||||
/** {0}=时刻 {1}=剩余分钟 {2}=标签 */
|
||||
'clock.nextFmtMin': '下一个 {0} · {1} 分钟后 · {2}',
|
||||
/** {0}=时刻 {1}=小时 {2}=分钟 {3}=标签 */
|
||||
'clock.nextFmtHm': '下一个 {0} · {1}h {2}m 后 · {3}',
|
||||
'clock.statEnabledSub': '闹钟',
|
||||
'clock.pomoStateIdle': '未开始',
|
||||
// 闹钟
|
||||
'alarms.title': '闹钟',
|
||||
'alarms.empty': '暂无闹钟',
|
||||
'alarms.emptyHint': '点击右上"+ 新建闹钟"开始',
|
||||
'alarms.newBtn': '+ 新建闹钟',
|
||||
'alarms.cancelNew': '取消新建',
|
||||
'alarms.edit': '编辑',
|
||||
'alarms.delete': '删除',
|
||||
'alarms.confirmDelete': '确认删除',
|
||||
'alarms.toggle': '启用',
|
||||
'alarms.toggleAria': '启用闹钟',
|
||||
'alarms.countFmt': '{0} / {1} 启用',
|
||||
'alarms.untitled': '(无标题)',
|
||||
'alarms.repeatOnce': '一次',
|
||||
'alarms.repeatDaily': '每日',
|
||||
// 编辑器
|
||||
'editor.newTitle': '新建闹钟',
|
||||
'editor.editTitle': '编辑闹钟',
|
||||
'editor.label': '标签',
|
||||
'editor.labelPh': '例如:晨间起床',
|
||||
'editor.time': '时间',
|
||||
'editor.save': '保存',
|
||||
'editor.saving': '保存中…',
|
||||
'editor.cancel': '取消',
|
||||
'editor.deleteBtn': '删除',
|
||||
'editor.timeInvalid': '时间格式错误',
|
||||
'editor.behaviorNotify': '系统通知',
|
||||
'editor.behaviorSilent': '静默',
|
||||
'editor.saveFailedPrefix': '保存失败:',
|
||||
'editor.deleteFailedPrefix': '删除失败:',
|
||||
'editor.settingsLoading': '设置尚未加载,请稍后重试。',
|
||||
'editor.defaultLabel': '闹钟',
|
||||
'editor.ariaSetTime': '设定闹钟时间',
|
||||
'editor.ariaHours': '小时',
|
||||
'editor.ariaMinutes': '分钟',
|
||||
'editor.ariaSeconds': '秒',
|
||||
'editor.srHours': '小时',
|
||||
'editor.srMinutes': '分钟',
|
||||
'editor.srSeconds': '秒',
|
||||
'editor.repeat': '每日重复',
|
||||
'editor.repeatOnce': '一次',
|
||||
'editor.repeatDaily': '每日',
|
||||
// 番茄钟
|
||||
'pomo.idle': '未开始',
|
||||
'pomo.focus': '专注',
|
||||
'pomo.shortBreak': '短休',
|
||||
'pomo.longBreak': '长休',
|
||||
'pomo.paused': '已暂停',
|
||||
'pomo.subFocus': '保持专注',
|
||||
'pomo.subBreak': '休息一下',
|
||||
'pomo.start': '开始',
|
||||
'pomo.pause': '暂停',
|
||||
'pomo.resume': '继续',
|
||||
'pomo.skip': '跳过',
|
||||
'pomo.reset': '重置',
|
||||
'pomo.today': '今日完成',
|
||||
'pomo.todayUnit': '个番茄',
|
||||
'pomo.footHint': '长休出现在每完成 4 轮之后',
|
||||
'pomo.roundPill': '第 {0} 轮',
|
||||
'pomo.ariaDisplay': '番茄钟显示',
|
||||
'pomo.ariaIdle': '番茄钟未开始,下一阶段 {0} {1} 分钟',
|
||||
'pomo.ariaRemain': '{0}{1},剩余 {2}',
|
||||
'pomo.ariaPaused': '(已暂停)',
|
||||
// 倒计时
|
||||
'countdown.title': '倒计时',
|
||||
'countdown.stateIdle': '空闲',
|
||||
'countdown.stateRunning': '运行中',
|
||||
'countdown.statePaused': '暂停',
|
||||
'countdown.ariaSetDuration': '设定时长',
|
||||
'countdown.ariaHours': '小时',
|
||||
'countdown.ariaMinutes': '分钟',
|
||||
'countdown.ariaSeconds': '秒',
|
||||
'countdown.ariaDisplay': '倒计时显示',
|
||||
'countdown.srHours': '小时',
|
||||
'countdown.srMinutes': '分钟',
|
||||
'countdown.srSeconds': '秒',
|
||||
'countdown.startBtn': '开始',
|
||||
'countdown.pauseBtn': '暂停',
|
||||
'countdown.resumeBtn': '继续',
|
||||
'countdown.resetBtn': '重置',
|
||||
'countdown.doneBtn': '完成',
|
||||
'countdown.timeUp': '时间到',
|
||||
'countdown.setN': '设定 {0}',
|
||||
'countdown.inputEmpty': '请输入时间',
|
||||
'countdown.ariaPausedFmt': '倒计时已暂停,剩余 {0}',
|
||||
'countdown.ariaRunningFmt': '倒计时运行中,剩余 {0}',
|
||||
'countdown.fmtHoursMinutes': '{0} 小时 {1} 分',
|
||||
'countdown.fmtMinutes': '{0} 分钟',
|
||||
'countdown.fmtSeconds': '{0} 秒',
|
||||
// 浮窗番茄钟的文案沿用 pomo.* 共享一份(id 全套启停/重置与主页一致);
|
||||
// 后续若浮窗需要独立文案(如"开始 / 暂停"按钮 title 这类)再拆 floating.pomo.*。
|
||||
// 浮窗闹钟
|
||||
'floating.alarms.next': '下一个',
|
||||
'floating.alarms.empty': '暂无闹钟',
|
||||
'floating.alarms.tagDefault': '闹钟',
|
||||
'floating.alarms.titleFmt': '{0} · 共 {1} 个启用',
|
||||
'floating.alarms.titleOnly': '{0}',
|
||||
'floating.alarms.countN': '共 {0} 个启用',
|
||||
'floating.alarms.diffSec': '{0} 秒后',
|
||||
'floating.alarms.diffMin': '{0} 分后',
|
||||
'floating.alarms.diffHM': '{0}h {1}m 后',
|
||||
'floating.alarms.diffH': '{0}h 后',
|
||||
// 浮窗倒计时
|
||||
'floating.cd.stateIdle': '空闲',
|
||||
'floating.cd.stateRunning': '运行中',
|
||||
'floating.cd.statePaused': '已暂停',
|
||||
// 浮窗时钟
|
||||
'floating.clock.dateFmt': '星期{0} {1}月{2}日',
|
||||
'floating.clock.weekdayChars': '日一二三四五六',
|
||||
// 设置面板
|
||||
'settings.title': '设置',
|
||||
'settings.sub': '个性化外观',
|
||||
'settings.skinTitle': '外观',
|
||||
'settings.skinDesc': '点击卡片即可切换整套配色,立即生效。',
|
||||
'settings.windowTitle': '窗口',
|
||||
'settings.alwaysOnTop': '窗口置顶',
|
||||
'settings.alwaysOnTopDesc': '让主窗口悬浮在其它窗口之上',
|
||||
'settings.modeDark': '暗色',
|
||||
'settings.modeLight': '亮色',
|
||||
'settings.aboutTitle': '关于',
|
||||
'settings.aboutDeveloper': '开发者:关济寰',
|
||||
'settings.aboutDeveloperAria': '访问关济寰的个人网站',
|
||||
'settings.aboutOpenFailed': '无法在系统浏览器中打开链接',
|
||||
'settings.foot': '所有设置都会自动保存到本地',
|
||||
// 通用
|
||||
'common.cancel': '取消',
|
||||
'toast.close': '关闭',
|
||||
// 错误(toast)
|
||||
'error.unhandled': '未捕获的错误:{0}',
|
||||
'error.unknown': '未知',
|
||||
'error.script': '脚本错误:{0}',
|
||||
} as const;
|
||||
|
||||
type Dict = typeof dict;
|
||||
|
||||
export function t(key: keyof Dict, ...args: unknown[]): string {
|
||||
let s = (dict as Record<string, string>)[key as string] ?? (key as string);
|
||||
if (args.length > 0) {
|
||||
s = s.replace(/\{(\d+)\}/g, (_m, idx) => String(args[Number(idx)] ?? ''));
|
||||
}
|
||||
return s;
|
||||
}
|
||||
14
src/renderer/index.html
Normal file
14
src/renderer/index.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'" />
|
||||
<title>Time</title>
|
||||
<link rel="stylesheet" href="./styles/global.css" />
|
||||
<link rel="stylesheet" href="./styles/themes.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="./main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
32
src/renderer/main.ts
Normal file
32
src/renderer/main.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { mount } from './App';
|
||||
|
||||
// 顶层 catch:mount() await bootstrap() 时如果 IPC 失败 / settings 数据损坏,
|
||||
// 没有 catch 就只是一个永远挂起的 promise + 一片空 #app,用户看到 0 反馈。
|
||||
// 提前挂 unhandledrejection / error 处理 + toast host,再 mount,
|
||||
// 这样 mount 失败时也能弹 toast。
|
||||
import { mountToastHost, toast } from './components/Toast';
|
||||
import { t } from './i18n';
|
||||
|
||||
// 提前挂一个 host:mount() 内部也会调一次,但因 ToastBus.attach 幂等,
|
||||
// 这里永远不会出现"两份 host"的视觉重复。挂 document.body 是为了
|
||||
// mount() 失败前(pre-mount)也能弹 toast。
|
||||
mountToastHost(document.body);
|
||||
window.addEventListener('error', (e) => {
|
||||
if (!e.message) return;
|
||||
toast(t('error.script', e.message), 'error');
|
||||
});
|
||||
window.addEventListener('unhandledrejection', (e) => {
|
||||
const reason = e.reason instanceof Error ? e.reason.message : String(e.reason);
|
||||
toast(t('error.unhandled', reason || t('error.unknown')), 'error');
|
||||
});
|
||||
|
||||
try {
|
||||
await mount(document.getElementById('app')!);
|
||||
} catch (err) {
|
||||
// mount() 抛错:bootstrap 拒绝 / settings 损坏 / 任一渲染组件崩。
|
||||
// 全局 handler 与 toast host 在 try 之前已挂好,这里直接弹。
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
toast(t('error.unhandled', msg || t('error.unknown')), 'error');
|
||||
// 控制台留完整 stack,方便调试
|
||||
console.error('[renderer] mount failed:', err);
|
||||
}
|
||||
79
src/renderer/secondTick.ts
Normal file
79
src/renderer/secondTick.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
// src/renderer/secondTick.ts
|
||||
// 统一的「每显示秒重画一次」调度器,替代各面板自己的 rAF / setTimeout(250) 循环。
|
||||
//
|
||||
// 为什么不是简单的 setInterval(1000):
|
||||
|
||||
// 重新导出浮窗需要的日期工具,避免浮窗再绕到 shared/constants 拉(仅模块内部使用)。
|
||||
export { localDateKey } from '../shared/constants';
|
||||
// 倒计时显示的是 Math.ceil((endsAt - now) / 1000),它的变化时刻锁在 endsAt 的相位上,
|
||||
// 跟墙上时钟的整秒边界没有关系。用整秒对齐的定时器驱动会产生 0~999ms 的固定滞后
|
||||
// (最坏情况下 00:00 会比铃声晚将近一秒才出现)——比它要替换掉的 240ms 闸门更差。
|
||||
// 所以这里睡到「显示值下一次真正改变的那一刻」:唤醒次数是 1 次/秒,而重画延迟
|
||||
// 反而从 ≤240ms 降到 ≤1 帧。
|
||||
//
|
||||
// 为什么必须处理 visibilitychange:
|
||||
// rAF 在窗口隐藏时天然停摆、显示时立即恢复;setTimeout 不是 —— 隐藏页面的定时器会被
|
||||
// Chromium 对齐到 1s,约 5 分钟后进一步降到 1 次/分钟。若不管,从托盘恢复主窗时会看到
|
||||
// 最长一分钟的陈旧读数。这里隐藏时直接停表、显示时立刻重画并重排,
|
||||
// 既复刻了 rAF 原本的语义,也比它更省(隐藏期间零唤醒)。
|
||||
|
||||
export interface SecondTicker {
|
||||
/** 状态变化后重排下一次唤醒(endsAt 变了、开始/暂停/重置了)。 */
|
||||
restart(): void;
|
||||
stop(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param draw 重画回调
|
||||
* @param nextAt 返回「显示值下一次改变」的绝对时间戳;返回 null 表示当前状态下
|
||||
* 显示值不会自己变化(idle / paused),此时不装定时器。
|
||||
*/
|
||||
export function createSecondTicker(draw: () => void, nextAt: () => number | null): SecondTicker {
|
||||
let timer = 0;
|
||||
|
||||
const clear = (): void => {
|
||||
if (timer) { window.clearTimeout(timer); timer = 0; }
|
||||
};
|
||||
|
||||
const schedule = (): void => {
|
||||
clear();
|
||||
if (document.hidden) return; // 隐藏期间不唤醒,show 时由 onVisibility 补画
|
||||
const at = nextAt();
|
||||
if (at === null) return;
|
||||
// clamp 到 [4, 1000]:即使 nextAt() 的相位算错,最坏也退化成 1Hz 轮询,
|
||||
// 不会比「每秒刷新一次」更差。
|
||||
const delay = Math.min(1000, Math.max(4, at - Date.now()));
|
||||
timer = window.setTimeout(() => {
|
||||
timer = 0;
|
||||
draw();
|
||||
schedule();
|
||||
}, delay);
|
||||
};
|
||||
|
||||
const onVisibility = (): void => {
|
||||
if (document.hidden) clear();
|
||||
else { draw(); schedule(); }
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', onVisibility);
|
||||
schedule();
|
||||
|
||||
return {
|
||||
restart: schedule,
|
||||
stop: () => {
|
||||
clear();
|
||||
document.removeEventListener('visibilitychange', onVisibility);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 倒数显示 Math.ceil(remain/1000) 下一次变化的绝对时刻。
|
||||
* 例:endsAt = now+5500 → 当前显示 6,将在 now+504 变成 5。
|
||||
* +4ms 让 ceil 稳定跨过边界,避免在临界点上重复算出同一个时刻。
|
||||
*/
|
||||
export function nextCeilBoundary(endsAt: number, now: number = Date.now()): number {
|
||||
const remain = Math.max(0, endsAt - now);
|
||||
const shown = Math.ceil(remain / 1000);
|
||||
return shown <= 0 ? now + 1000 : endsAt - (shown - 1) * 1000 + 4;
|
||||
}
|
||||
183
src/renderer/store.ts
Normal file
183
src/renderer/store.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import { DEFAULT_REMINDER, DEFAULT_SETTINGS, localDateKey } from '../shared/constants';
|
||||
import { DEFAULT_SKIN_ID, normalizeSkinId } from '../shared/theme';
|
||||
import { formatLocalHMS } from '../shared/time';
|
||||
import type {
|
||||
Alarm, AppState, PomodoroActiveState, PomodoroHistory,
|
||||
Route, Settings, CountdownActiveState, FloatingKind
|
||||
} from '../shared/types';
|
||||
// 上面 localDateKey 仅在首屏初始化 clockStr / PomodoroPanel 的 dayTimer 兜底用,
|
||||
// 本文件不再单独维护 dayKey atom。
|
||||
|
||||
type Listener<T> = (v: T) => void;
|
||||
|
||||
function withDefaults(s: AppState['settings'] | null): Settings {
|
||||
// 兼容老存档:settings 可能没有 reminder 字段(甚至 reminder.behaviors 缺省),
|
||||
// 用 DEFAULT_REMINDER 兜底。早期实现里 `behaviors: [...base.reminder.behaviors]`
|
||||
// 没判 undefined → 一旦 reminder 缺 behaviors 就会抛 TypeError,把 bootstrap 拖崩
|
||||
// 进而让用户看到空窗(详见 App.ts mount 的兜底说明)。
|
||||
const base = s ?? DEFAULT_SETTINGS;
|
||||
const baseBehaviors = Array.isArray(base.reminder?.behaviors)
|
||||
? [...base.reminder.behaviors]
|
||||
: [...DEFAULT_REMINDER.behaviors];
|
||||
return {
|
||||
...DEFAULT_SETTINGS,
|
||||
...base,
|
||||
reminder: base.reminder
|
||||
? { ...DEFAULT_REMINDER, ...base.reminder, behaviors: baseBehaviors }
|
||||
: { ...DEFAULT_REMINDER, behaviors: baseBehaviors }
|
||||
};
|
||||
}
|
||||
|
||||
// 浅比较:只看顶层 key。对原子 store(对象类型的 settings/alarms/pomodoro/history 等)
|
||||
// 足以避免一次 update 触发 N 个无变化的 listener。对嵌套对象(数组里的某一项)则不适用,
|
||||
// 此时调用方需要自己保证引用变更。
|
||||
function shallowEqual(a: unknown, b: unknown): boolean {
|
||||
if (Object.is(a, b)) return true;
|
||||
if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false;
|
||||
const ka = Object.keys(a as object);
|
||||
const kb = Object.keys(b as object);
|
||||
if (ka.length !== kb.length) return false;
|
||||
for (const k of ka) {
|
||||
if (!Object.is((a as Record<string, unknown>)[k], (b as Record<string, unknown>)[k])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 结构相等(递归)。state 全是 JSON 形状(对象 / 数组 / 原始值),所以直接递归即可。
|
||||
*
|
||||
* 为什么需要它:主进程每次 storageChanged 推的都是 JSON.parse(JSON.stringify(...))
|
||||
* 出来的全新深拷贝,数组和嵌套对象的引用必然与上一份不同 —— 浅比较在 alarms[0]
|
||||
* 这一层就判定"变了",于是**改一次音量也会让整个闹钟列表 innerHTML 重建、
|
||||
* 每行重绑监听、皮肤网格与行为胶囊一起重画**。按值比较后,只有内容真的变了才通知。 */
|
||||
function deepEqual(a: unknown, b: unknown): boolean {
|
||||
if (Object.is(a, b)) return true;
|
||||
if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false;
|
||||
const aArr = Array.isArray(a);
|
||||
if (aArr !== Array.isArray(b)) return false;
|
||||
if (aArr) {
|
||||
const x = a as unknown[];
|
||||
const y = b as unknown[];
|
||||
if (x.length !== y.length) return false;
|
||||
return x.every((v, i) => deepEqual(v, y[i]));
|
||||
}
|
||||
const ka = Object.keys(a as object);
|
||||
const kb = Object.keys(b as object);
|
||||
if (ka.length !== kb.length) return false;
|
||||
return ka.every(k =>
|
||||
Object.prototype.hasOwnProperty.call(b, k) &&
|
||||
deepEqual((a as Record<string, unknown>)[k], (b as Record<string, unknown>)[k])
|
||||
);
|
||||
}
|
||||
|
||||
class Atom<T> {
|
||||
private value: T;
|
||||
private listeners = new Set<Listener<T>>();
|
||||
/** eq 默认浅比较;承载 IPC 深拷贝快照的原子传 deepEqual(见下方 store 定义)。 */
|
||||
constructor(initial: T, private eq: (a: unknown, b: unknown) => boolean = shallowEqual) { this.value = initial; }
|
||||
get(): T { return this.value; }
|
||||
set(v: T): void {
|
||||
// 同值不广播 → 避免拖音量条 / 高频 input 时所有 listener 跟着重画。
|
||||
if (this.eq(this.value, v)) return;
|
||||
this.value = v;
|
||||
this.listeners.forEach(l => l(v));
|
||||
}
|
||||
subscribe(l: Listener<T>): () => void {
|
||||
this.listeners.add(l);
|
||||
return () => { this.listeners.delete(l); };
|
||||
}
|
||||
}
|
||||
|
||||
export const store = {
|
||||
// 以下原子的值都来自主进程的全量深拷贝快照,必须按值比较(见 deepEqual 注释)。
|
||||
alarms: new Atom<Alarm[]>([], deepEqual),
|
||||
settings: new Atom<Settings | null>(null, deepEqual),
|
||||
pomodoro: new Atom<{
|
||||
active: PomodoroActiveState | null;
|
||||
rounds: number;
|
||||
history: PomodoroHistory;
|
||||
}>({
|
||||
active: null, rounds: 0, history: { date: '', count: 0 }
|
||||
}, deepEqual),
|
||||
countdown: new Atom<{
|
||||
active: CountdownActiveState | null;
|
||||
lastDurationMs: number;
|
||||
}>({
|
||||
active: null, lastDurationMs: 5 * 60 * 1000
|
||||
}, deepEqual),
|
||||
// clockStr 初值用本地时间(避免渲染进程启动到首个 IPC clockTick 之间闪现 "--:--:--")。
|
||||
// formatLocalHMS 始终返回合法 "HH:MM:SS",若 Date 构造异常(极少见,如系统时间被破坏)
|
||||
// 会得到 "NaN:NaN:NaN" —— 这种情况下 ClockPanel.ts 的 isValidHHMMSS 会兜底再 new Date()。
|
||||
clockStr: new Atom<string>(formatLocalHMS(new Date())),
|
||||
route: new Atom<Route>('clock'),
|
||||
topbar: new Atom<{ alwaysOnTop: boolean }>({ alwaysOnTop: false }),
|
||||
/** 当前生效的皮肤 id —— themes.css 据此决定变量覆盖 */
|
||||
skin: new Atom<string>(DEFAULT_SKIN_ID),
|
||||
/** 4 个浮窗小卡片的开关状态(topbar 按钮 on/off 指示 + aria-pressed 同步)。 */
|
||||
floatingOpen: new Atom<Record<FloatingKind, boolean>>({
|
||||
pomodoro: false, clock: false, alarms: false, countdown: false
|
||||
})
|
||||
};
|
||||
|
||||
// 与 ClockPanel.ts 中相同的格式校验,集中放在这里便于后续抽到 shared/time.ts 复用。
|
||||
// 保留 ClockPanel.ts 内的本地副本以避免组件渲染时再走一次跨模块依赖。
|
||||
const HHMMSS_RE = /^\d{2}:\d{2}:\d{2}$/;
|
||||
function isValidHHMMSS(s: unknown): s is string {
|
||||
return typeof s === 'string' && HHMMSS_RE.test(s);
|
||||
}
|
||||
|
||||
export async function bootstrap(): Promise<() => void> {
|
||||
const s = await window.api.clock.getState();
|
||||
store.alarms.set(s.alarms);
|
||||
store.settings.set(withDefaults(s.settings));
|
||||
store.pomodoro.set({
|
||||
active: s.pomodoro.active,
|
||||
rounds: s.pomodoro.completedRounds,
|
||||
history: s.pomodoro.history
|
||||
});
|
||||
store.countdown.set({
|
||||
active: s.countdown.active,
|
||||
lastDurationMs: s.countdown.lastDurationMs
|
||||
});
|
||||
store.topbar.set({ alwaysOnTop: s.settings.alwaysOnTop === true });
|
||||
// 旧存档(无 themeId 字段)会回退 DEFAULT_SETTINGS.themeId;如果是未知值
|
||||
// normalizeSkinId 再兜一次,避免把 CSS 选择器写成 data-skin="garbage"。
|
||||
const skinId = normalizeSkinId((s.settings as Settings).themeId ?? DEFAULT_SETTINGS.themeId);
|
||||
store.skin.set(skinId);
|
||||
|
||||
// 把所有 IPC 订阅的 unsubscribe 收集起来 → HMR / 测试场景下二次 bootstrap 时
|
||||
// 旧 listener 不会被叠成 N 份。返回的 dispose 函数给调用方在需要时调一次。
|
||||
const unsubs: Array<() => void> = [
|
||||
// IPC clockTick 校验:主进程正常发 "HH:MM:SS",但 webContents.send 在 channel disposed
|
||||
// 期间可能让渲染端拿到 undefined/空串(理论不该发生,但 1Hz 跑一整天积累起来就会暴露)。
|
||||
// 非法值直接丢弃,保留当前 store 值;ClockPanel 的 isValidHHMMSS 是最后一道兜底。
|
||||
window.api.on.clockTick((t) => {
|
||||
if (isValidHHMMSS(t)) store.clockStr.set(t);
|
||||
}),
|
||||
window.api.on.storageChanged((s) => {
|
||||
store.alarms.set(s.alarms); store.settings.set(withDefaults(s.settings));
|
||||
store.pomodoro.set({
|
||||
active: s.pomodoro.active,
|
||||
rounds: s.pomodoro.completedRounds,
|
||||
history: s.pomodoro.history
|
||||
});
|
||||
store.countdown.set({
|
||||
active: s.countdown.active,
|
||||
lastDurationMs: s.countdown.lastDurationMs
|
||||
});
|
||||
store.topbar.set({ alwaysOnTop: s.settings.alwaysOnTop === true });
|
||||
store.skin.set(normalizeSkinId((s.settings as Settings).themeId ?? DEFAULT_SETTINGS.themeId));
|
||||
}),
|
||||
window.api.on.alarmFired((_a) => { /* 主进程已广播 storageChanged,UI 自行重画 */ }),
|
||||
window.api.on.pomodoroPhaseChanged((active, rounds) => {
|
||||
const cur = store.pomodoro.get();
|
||||
store.pomodoro.set({ active, rounds, history: cur.history });
|
||||
}),
|
||||
window.api.on.countdownPhaseChanged((active) => {
|
||||
store.countdown.set({ active, lastDurationMs: store.countdown.get().lastDurationMs });
|
||||
}),
|
||||
window.api.on.topbarChanged((t) => { store.topbar.set(t); }),
|
||||
window.api.on.floatingWindowsChanged((open) => { store.floatingOpen.set(open); })
|
||||
];
|
||||
|
||||
return () => { for (const u of unsubs) u(); };
|
||||
}
|
||||
1998
src/renderer/styles/global.css
Normal file
1998
src/renderer/styles/global.css
Normal file
File diff suppressed because it is too large
Load Diff
258
src/renderer/styles/themes.css
Normal file
258
src/renderer/styles/themes.css
Normal file
@@ -0,0 +1,258 @@
|
||||
/* =========================================================================
|
||||
Skin variants — 渲染端换肤
|
||||
-------------------------------------------------------------------------
|
||||
一切变量在 :root 默认值里给出(global.css 提供 Linear Dark 默认皮肤)。
|
||||
这里每个皮肤用 :root[data-skin="<id>"] 选择器覆盖整套 CSS 变量。
|
||||
选择任一皮肤即生效;同时设置 <html data-skin data-theme>。
|
||||
========================================================================= */
|
||||
|
||||
/* === Linear Dark(默认皮肤,也用作 fallback) === */
|
||||
:root[data-skin="linear-dark"],
|
||||
:root:not([data-skin]) {
|
||||
--bg: #08080B;
|
||||
--bg-elev-1: #0E0E12;
|
||||
--bg-elev-2: #13141A;
|
||||
--bg-elev-3: #1A1B22;
|
||||
--sidebar-bg: #0A0A0C;
|
||||
|
||||
--line: rgba(255, 255, 255, 0.06);
|
||||
--line-strong: rgba(255, 255, 255, 0.10);
|
||||
--line-soft: rgba(255, 255, 255, 0.04);
|
||||
|
||||
--text: #F2F2F5;
|
||||
--text-dim: #B4B4BC;
|
||||
--text-faint: #9090A0;
|
||||
|
||||
--accent: #5E6AD2;
|
||||
--accent-hover: #6E78E0;
|
||||
--accent-soft: rgba(94, 106, 210, 0.14);
|
||||
--accent-line: rgba(94, 106, 210, 0.40);
|
||||
--accent-glow: rgba(94, 106, 210, 0.20);
|
||||
--accent-2: #7C8AE5;
|
||||
--accent-2-soft:rgba(124, 138, 229, 0.14);
|
||||
--accent-2-line:rgba(124, 138, 229, 0.40);
|
||||
--on-accent: #FFFFFF;
|
||||
|
||||
--warn: #D9695E;
|
||||
--warn-soft: rgba(217, 105, 94, 0.12);
|
||||
--warn-line: rgba(217, 105, 94, 0.40);
|
||||
|
||||
--ok: #4CB782;
|
||||
--ok-soft: rgba(76, 183, 130, 0.14);
|
||||
--ok-line: rgba(76, 183, 130, 0.40);
|
||||
|
||||
--shadow-1: 0 1px 2px rgba(0, 0, 0, 0.4);
|
||||
--shadow-2: 0 12px 28px rgba(0, 0, 0, 0.45);
|
||||
--shadow-3: 0 24px 60px rgba(0, 0, 0, 0.55);
|
||||
--inset-1: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
/* === Brass Dark(暗金 · 复古仪表盘) === */
|
||||
:root[data-skin="brass-dark"] {
|
||||
--bg: #1A1612;
|
||||
--bg-elev-1: #221C16;
|
||||
--bg-elev-2: #2B231B;
|
||||
--bg-elev-3: #38291E;
|
||||
--sidebar-bg: #18130F;
|
||||
|
||||
--line: rgba(255, 235, 200, 0.08);
|
||||
--line-strong: rgba(255, 235, 200, 0.14);
|
||||
--line-soft: rgba(255, 235, 200, 0.04);
|
||||
|
||||
--text: #F1E4C7;
|
||||
--text-dim: #C9B998;
|
||||
--text-faint: #8A7E66;
|
||||
|
||||
--accent: #D9B25F;
|
||||
--accent-hover: #E5C078;
|
||||
--accent-soft: rgba(217, 178, 95, 0.14);
|
||||
--accent-line: rgba(217, 178, 95, 0.45);
|
||||
--accent-glow: rgba(217, 178, 95, 0.22);
|
||||
--accent-2: #E8C77A;
|
||||
--accent-2-soft:rgba(232, 199, 122, 0.14);
|
||||
--accent-2-line:rgba(232, 199, 122, 0.45);
|
||||
--on-accent: #1A1612;
|
||||
|
||||
--warn: #C97050;
|
||||
--warn-soft: rgba(201, 112, 80, 0.14);
|
||||
--warn-line: rgba(201, 112, 80, 0.45);
|
||||
|
||||
--ok: #7BA47B;
|
||||
--ok-soft: rgba(123, 164, 123, 0.14);
|
||||
--ok-line: rgba(123, 164, 123, 0.45);
|
||||
|
||||
--shadow-1: 0 1px 2px rgba(0, 0, 0, 0.55);
|
||||
--shadow-2: 0 12px 28px rgba(0, 0, 0, 0.55);
|
||||
--shadow-3: 0 24px 60px rgba(0, 0, 0, 0.60);
|
||||
--inset-1: inset 0 1px 0 rgba(255, 220, 160, 0.06);
|
||||
}
|
||||
|
||||
/* === Solarized Dark === */
|
||||
:root[data-skin="solarized-dark"] {
|
||||
--bg: #002B36;
|
||||
--bg-elev-1: #073642;
|
||||
--bg-elev-2: #0A4150;
|
||||
--bg-elev-3: #134E5E;
|
||||
--sidebar-bg: #001F27;
|
||||
|
||||
--line: rgba(147, 161, 161, 0.20);
|
||||
--line-strong: rgba(147, 161, 161, 0.32);
|
||||
--line-soft: rgba(147, 161, 161, 0.10);
|
||||
|
||||
--text: #EEE8D5;
|
||||
--text-dim: #93A1A1;
|
||||
--text-faint: #657B83;
|
||||
|
||||
--accent: #268BD2;
|
||||
--accent-hover: #3A9BE0;
|
||||
--accent-soft: rgba(38, 139, 210, 0.18);
|
||||
--accent-line: rgba(38, 139, 210, 0.45);
|
||||
--accent-glow: rgba(38, 139, 210, 0.22);
|
||||
--accent-2: #B58900;
|
||||
--accent-2-soft:rgba(181, 137, 0, 0.18);
|
||||
--accent-2-line:rgba(181, 137, 0, 0.45);
|
||||
--on-accent: #FDF6E3;
|
||||
|
||||
--warn: #DC322F;
|
||||
--warn-soft: rgba(220, 50, 47, 0.18);
|
||||
--warn-line: rgba(220, 50, 47, 0.45);
|
||||
|
||||
--ok: #859900;
|
||||
--ok-soft: rgba(133, 153, 0, 0.18);
|
||||
--ok-line: rgba(133, 153, 0, 0.45);
|
||||
|
||||
--shadow-1: 0 1px 2px rgba(0, 0, 0, 0.45);
|
||||
--shadow-2: 0 12px 28px rgba(0, 0, 0, 0.50);
|
||||
--shadow-3: 0 24px 60px rgba(0, 0, 0, 0.60);
|
||||
--inset-1: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
/* === Nord(北极冷色) === */
|
||||
:root[data-skin="nord"] {
|
||||
--bg: #2E3440;
|
||||
--bg-elev-1: #3B4252;
|
||||
--bg-elev-2: #434C5E;
|
||||
--bg-elev-3: #4C566A;
|
||||
--sidebar-bg: #2A303D;
|
||||
|
||||
--line: rgba(229, 233, 240, 0.10);
|
||||
--line-strong: rgba(229, 233, 240, 0.18);
|
||||
--line-soft: rgba(229, 233, 240, 0.05);
|
||||
|
||||
--text: #ECEFF4;
|
||||
--text-dim: #D8DEE9;
|
||||
--text-faint: #88909D;
|
||||
|
||||
--accent: #88C0D0;
|
||||
--accent-hover: #9FCDDB;
|
||||
--accent-soft: rgba(136, 192, 208, 0.18);
|
||||
--accent-line: rgba(136, 192, 208, 0.45);
|
||||
--accent-glow: rgba(136, 192, 208, 0.22);
|
||||
--accent-2: #81A1C1;
|
||||
--accent-2-soft:rgba(129, 161, 193, 0.18);
|
||||
--accent-2-line:rgba(129, 161, 193, 0.45);
|
||||
--on-accent: #2E3440;
|
||||
|
||||
--warn: #BF616A;
|
||||
--warn-soft: rgba(191, 97, 106, 0.18);
|
||||
--warn-line: rgba(191, 97, 106, 0.45);
|
||||
|
||||
--ok: #A3BE8C;
|
||||
--ok-soft: rgba(163, 190, 140, 0.18);
|
||||
--ok-line: rgba(163, 190, 140, 0.45);
|
||||
|
||||
--shadow-1: 0 1px 2px rgba(0, 0, 0, 0.40);
|
||||
--shadow-2: 0 12px 28px rgba(0, 0, 0, 0.50);
|
||||
--shadow-3: 0 24px 60px rgba(0, 0, 0, 0.60);
|
||||
--inset-1: inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
/* === Catppuccin Mocha === */
|
||||
:root[data-skin="catppuccin"] {
|
||||
--bg: #1E1E2E;
|
||||
--bg-elev-1: #181825;
|
||||
--bg-elev-2: #313244;
|
||||
--bg-elev-3: #45475A;
|
||||
--sidebar-bg: #181825;
|
||||
|
||||
--line: rgba(205, 214, 244, 0.10);
|
||||
--line-strong: rgba(205, 214, 244, 0.18);
|
||||
--line-soft: rgba(205, 214, 244, 0.05);
|
||||
|
||||
--text: #CDD6F4;
|
||||
--text-dim: #BAC2DE;
|
||||
--text-faint: #6C7086;
|
||||
|
||||
--accent: #CBA6F7;
|
||||
--accent-hover: #D6B6FA;
|
||||
--accent-soft: rgba(203, 166, 247, 0.18);
|
||||
--accent-line: rgba(203, 166, 247, 0.45);
|
||||
--accent-glow: rgba(203, 166, 247, 0.22);
|
||||
--accent-2: #F38BA8;
|
||||
--accent-2-soft:rgba(243, 139, 168, 0.18);
|
||||
--accent-2-line:rgba(243, 139, 168, 0.45);
|
||||
--on-accent: #1E1E2E;
|
||||
|
||||
--warn: #F38BA8;
|
||||
--warn-soft: rgba(243, 139, 168, 0.18);
|
||||
--warn-line: rgba(243, 139, 168, 0.45);
|
||||
|
||||
--ok: #A6E3A1;
|
||||
--ok-soft: rgba(166, 227, 161, 0.18);
|
||||
--ok-line: rgba(166, 227, 161, 0.45);
|
||||
|
||||
--shadow-1: 0 1px 2px rgba(0, 0, 0, 0.45);
|
||||
--shadow-2: 0 12px 28px rgba(0, 0, 0, 0.55);
|
||||
--shadow-3: 0 24px 60px rgba(0, 0, 0, 0.65);
|
||||
--inset-1: inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
/* === Vercel / Geist Light === */
|
||||
:root[data-skin="vercel-light"] {
|
||||
--bg: #FFFFFF;
|
||||
--bg-elev-1: #FAFAFA;
|
||||
--bg-elev-2: #F4F4F5;
|
||||
--bg-elev-3: #E4E4E7;
|
||||
--sidebar-bg: #FAFAFA;
|
||||
|
||||
--line: rgba(20, 22, 38, 0.08);
|
||||
--line-strong: rgba(20, 22, 38, 0.14);
|
||||
--line-soft: rgba(20, 22, 38, 0.04);
|
||||
|
||||
--text: #18181B;
|
||||
--text-dim: #50525A;
|
||||
--text-faint: #A1A1AA;
|
||||
|
||||
--accent: #5E6AD2;
|
||||
--accent-hover: #4F5BC0;
|
||||
--accent-soft: rgba(94, 106, 210, 0.10);
|
||||
--accent-line: rgba(94, 106, 210, 0.40);
|
||||
--accent-glow: rgba(94, 106, 210, 0.18);
|
||||
--accent-2: #6E78C7;
|
||||
--accent-2-soft:rgba(110, 120, 199, 0.10);
|
||||
--accent-2-line:rgba(110, 120, 199, 0.40);
|
||||
--on-accent: #FFFFFF;
|
||||
|
||||
--warn: #DC2626;
|
||||
--warn-soft: rgba(220, 38, 38, 0.10);
|
||||
--warn-line: rgba(220, 38, 38, 0.40);
|
||||
|
||||
--ok: #16A34A;
|
||||
--ok-soft: rgba(22, 163, 74, 0.10);
|
||||
--ok-line: rgba(22, 163, 74, 0.40);
|
||||
|
||||
--shadow-1: 0 1px 2px rgba(20, 22, 38, 0.06);
|
||||
--shadow-2: 0 12px 28px rgba(20, 22, 38, 0.10), 0 2px 4px rgba(20, 22, 38, 0.06);
|
||||
--shadow-3: 0 24px 60px rgba(20, 22, 38, 0.14);
|
||||
--inset-1: inset 0 1px 0 rgba(255, 255, 255, 0.60);
|
||||
}
|
||||
|
||||
/* 兼容旧 data-theme/data-palette 写法:视作 linear-dark 兜底。
|
||||
旧版本如果设置了 [data-theme="light"][data-palette="vscode"] 等无效组合,
|
||||
也直接落到 linear-dark,避免显示破图。 */
|
||||
:root[data-theme]:not([data-skin]) {
|
||||
--bg: #08080B;
|
||||
--bg-elev-1: #0E0E12;
|
||||
--bg-elev-2: #13141A;
|
||||
--bg-elev-3: #1A1B22;
|
||||
}
|
||||
41
src/renderer/themeApply.ts
Normal file
41
src/renderer/themeApply.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
// src/renderer/themeApply.ts
|
||||
// 把 store 中的 skin 写入 <html data-skin data-theme>。CSS 端 themes.css 据此覆盖变量。
|
||||
// 单点写入:把 dataset 的更新收敛到一对订阅,任何来源(bootstrap / setSkin /
|
||||
// storageChanged)都不用单独写 DOM,store 一变,DOM 自动同步。
|
||||
// 必须先 import './store' 然后才能用其副作用 —— 注意 import 顺序,单测里也是。
|
||||
import { store } from './store';
|
||||
import { modeForSkin, normalizeSkinId, type SkinId } from '../shared/theme';
|
||||
|
||||
// HMR 重入时(HMR 反复调 mount() → applyTheme())必须保证只有一个全局订阅在写 DOM。
|
||||
// 模块级变量持有最近一次安装的订阅的 dispose,HMR 重挂时先解绑旧的,再装新的。
|
||||
let installed: (() => void) | null = null;
|
||||
|
||||
export function applyTheme(): void {
|
||||
// 1. 第一次挂载 / 后续每次重挂:把当前值写到 DOM(避免订阅触发之前 UI 闪默认值)。
|
||||
const id = store.skin.get();
|
||||
document.documentElement.dataset.skin = id;
|
||||
document.documentElement.dataset.theme = modeForSkin(id);
|
||||
// 2. 注册响应式订阅:之后任何来源改变 store 都会自动同步到 DOM。
|
||||
// data-theme 跟着 skin 派生 —— 与浮窗 floating-shared.ts 用同一份 modeForSkin,
|
||||
// 保证主窗与浮窗在切换 Vercel Light 等亮色皮肤时同时进入 light 模式。
|
||||
// 卸载上一轮(如果存在),避免 HMR 重复挂载造成订阅堆积、DOM 多次写入。
|
||||
installed?.();
|
||||
installed = store.skin.subscribe((next) => {
|
||||
document.documentElement.dataset.skin = next;
|
||||
document.documentElement.dataset.theme = modeForSkin(next);
|
||||
});
|
||||
}
|
||||
|
||||
/** 让 React/组件直接调用:原子更新 skin 同时落盘 settings(IPC 异步)。 */
|
||||
export function setSkin(id: string): void {
|
||||
const next: SkinId = normalizeSkinId(id);
|
||||
store.skin.set(next);
|
||||
// dataset.skin / dataset.theme 由 store.skin 订阅自动写入,这里不再手动写。
|
||||
void window.api.settings.set({ themeId: next });
|
||||
}
|
||||
|
||||
/** HMR dispose 钩子:解除 store.skin 订阅,避免重挂时叠加。 */
|
||||
export function disposeTheme(): void {
|
||||
installed?.();
|
||||
installed = null;
|
||||
}
|
||||
6
src/renderer/types.d.ts
vendored
Normal file
6
src/renderer/types.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { AppApi } from '../preload';
|
||||
|
||||
declare global {
|
||||
interface Window { api: AppApi }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user