一个用于演练/演示的桌面时钟工具:两个窗口 —— 控制窗口(普通)与时钟窗口(无边框、 可拖动、可拖边缘改大小,由控制窗口开与关)。 功能 - 两个窗口各自的「始终置顶」开关 - 显示样式:纯数字 / 圆形表盘,切换立即生效 - 实时设定时间(含「填入当前显示时间」) - 倍率:支持正数、负数(倒转)、0(暂停)、小数 - 暂停 / 继续(继续时回到暂停前的倍率,按钮文案会显示是几倍) - 快进五档:+5 秒 / +1 分钟 / +5 分钟 / +10 分钟 / +30 分钟 实现要点 - 走时是「基准时间 + 基准时刻 + 倍率」的锚点式模型(不累加、零漂移); 倍率、暂停、倒转、快进因此是同一套机制的四个用法,互不打架 - 跳转动画做在「呈现时间」这一层:数字滚动与表盘指针扫动共用同一个中间值, 所以永远同步,也不会跨 12 点倒着转一圈 - 无边框窗口的拖动(CSS app-region + 控件 no-drag)与拖边缘改大小 (自己算几何 + IPC setBounds,因为 frame:false 下系统边缘命中不可靠) 测试 152 例:走时模型 / 改大小几何 / 呈现时间 / 两个渲染进程(DOM 桩)
146 lines
4.8 KiB
JavaScript
146 lines
4.8 KiB
JavaScript
'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();
|
||
});
|