Files
ClockLab/renderer/control.js
T
AskaEth 9c7081b21d feat: ClockLab —— 可调速桌面时钟(数字/表盘,支持倍率、暂停、倒转、快进)
一个用于演练/演示的桌面时钟工具:两个窗口 —— 控制窗口(普通)与时钟窗口(无边框、
可拖动、可拖边缘改大小,由控制窗口开与关)。

功能
- 两个窗口各自的「始终置顶」开关
- 显示样式:纯数字 / 圆形表盘,切换立即生效
- 实时设定时间(含「填入当前显示时间」)
- 倍率:支持正数、负数(倒转)、0(暂停)、小数
- 暂停 / 继续(继续时回到暂停前的倍率,按钮文案会显示是几倍)
- 快进五档:+5 秒 / +1 分钟 / +5 分钟 / +10 分钟 / +30 分钟

实现要点
- 走时是「基准时间 + 基准时刻 + 倍率」的锚点式模型(不累加、零漂移);
  倍率、暂停、倒转、快进因此是同一套机制的四个用法,互不打架
- 跳转动画做在「呈现时间」这一层:数字滚动与表盘指针扫动共用同一个中间值,
  所以永远同步,也不会跨 12 点倒着转一圈
- 无边框窗口的拖动(CSS app-region + 控件 no-drag)与拖边缘改大小
  (自己算几何 + IPC setBounds,因为 frame:false 下系统边缘命中不可靠)

测试 152 例:走时模型 / 改大小几何 / 呈现时间 / 两个渲染进程(DOM 桩)
2026-09-15 01:31:07 +08:00

146 lines
4.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
const api = window.clockApi;
const els = {
readoutTime: document.getElementById('readoutTime'),
readoutMeta: document.getElementById('readoutMeta'),
toggleClockWindow: document.getElementById('toggleClockWindow'),
aotClock: document.getElementById('aotClock'),
aotControl: document.getElementById('aotControl'),
styleGroup: document.getElementById('styleGroup'),
hh: document.getElementById('hh'),
mm: document.getElementById('mm'),
ss: document.getElementById('ss'),
applyTime: document.getElementById('applyTime'),
fillNow: document.getElementById('fillNow'),
rate: document.getElementById('rate'),
applyRate: document.getElementById('applyRate'),
togglePause: document.getElementById('togglePause')
};
let snapshot = { baseMs: Date.now(), baseRealMs: Date.now(), rate: 1, style: 'digital', paused: false, clockWindowOpen: false };
function pad2(value) {
return String(value).padStart(2, '0');
}
function displayMs(now) {
return snapshot.baseMs + (now - snapshot.baseRealMs) * snapshot.rate;
}
function clampInt(value, min, max, fallback) {
const n = Number.parseInt(value, 10);
if (!Number.isFinite(n)) return fallback;
return Math.min(max, Math.max(min, n));
}
function renderReadout() {
const ms = displayMs(Date.now());
const date = new Date(ms);
els.readoutTime.textContent = `${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}`;
const rateText = snapshot.rate === 0 ? '暂停' : `×${snapshot.rate}`;
const runText = snapshot.rate === 0 ? '已暂停' : snapshot.rate < 0 ? '倒转中' : '运行中';
els.readoutMeta.textContent = `${rateText} · ${runText} · 时钟窗口${snapshot.clockWindowOpen ? '已打开' : '已关闭'}`;
requestAnimationFrame(renderReadout);
}
function syncControls() {
els.toggleClockWindow.textContent = snapshot.clockWindowOpen ? '关闭时钟窗口' : '打开时钟窗口';
els.toggleClockWindow.classList.toggle('is-on', snapshot.clockWindowOpen);
els.aotClock.checked = Boolean(snapshot.alwaysOnTop.clock);
els.aotControl.checked = Boolean(snapshot.alwaysOnTop.control);
for (const button of els.styleGroup.querySelectorAll('.seg')) {
button.classList.toggle('is-active', button.dataset.style === snapshot.style);
}
// 用户正在输入时不要抢走他的光标和内容
if (document.activeElement !== els.rate) els.rate.value = String(snapshot.rate);
if (snapshot.rate === 0) {
els.togglePause.textContent = `继续(回到 ×${snapshot.lastRate})`;
els.togglePause.classList.add('is-on');
} else {
els.togglePause.textContent = '暂停';
els.togglePause.classList.remove('is-on');
}
}
function accept(next) {
if (next) {
snapshot = next;
syncControls();
}
}
els.toggleClockWindow.addEventListener('click', async () => accept(await api.toggleClockWindow()));
els.aotClock.addEventListener('change', async () => accept(await api.setAlwaysOnTop('clock', els.aotClock.checked)));
els.aotControl.addEventListener('change', async () => accept(await api.setAlwaysOnTop('control', els.aotControl.checked)));
for (const button of els.styleGroup.querySelectorAll('.seg')) {
button.addEventListener('click', async () => accept(await api.setStyle(button.dataset.style)));
}
els.applyTime.addEventListener('click', async () => {
const h = clampInt(els.hh.value, 0, 23, 0);
const m = clampInt(els.mm.value, 0, 59, 0);
const s = clampInt(els.ss.value, 0, 59, 0);
els.hh.value = String(h);
els.mm.value = String(m);
els.ss.value = String(s);
accept(await api.setTime(h, m, s));
});
els.fillNow.addEventListener('click', () => {
// 三格都要补零:`String(0)` 是 "0",而输入框按 HH:MM:SS 解析,
// 上午 9 点前会填出 "0:39:27" 这种非法值,点应用时解析失败。
const date = new Date(displayMs(Date.now()));
els.hh.value = pad2(date.getHours());
els.mm.value = pad2(date.getMinutes());
els.ss.value = pad2(date.getSeconds());
});
function applyRateInput() {
const value = Number(els.rate.value);
if (!Number.isFinite(value)) {
els.rate.value = String(snapshot.rate);
return;
}
api.setRate(value).then(accept);
}
els.applyRate.addEventListener('click', applyRateInput);
els.rate.addEventListener('keydown', (event) => {
if (event.key === 'Enter') applyRateInput();
});
els.togglePause.addEventListener('click', async () => accept(await api.togglePause()));
document.body.addEventListener('click', (event) => {
const rate = event.target.dataset?.rate;
if (rate !== undefined) {
api.setRate(Number(rate)).then(accept);
return;
}
const delta = event.target.dataset?.delta;
if (delta !== undefined) {
api.fastForward(Number(delta)).then(accept);
}
});
api.onState((next) => {
snapshot = next;
syncControls();
});
api.getState().then((initial) => {
snapshot = initial;
syncControls();
renderReadout();
});