feat: ClockLab —— 可调速桌面时钟(数字/表盘,支持倍率、暂停、倒转、快进)

一个用于演练/演示的桌面时钟工具:两个窗口 —— 控制窗口(普通)与时钟窗口(无边框、
可拖动、可拖边缘改大小,由控制窗口开与关)。

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

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

测试 152 例:走时模型 / 改大小几何 / 呈现时间 / 两个渲染进程(DOM 桩)
This commit is contained in:
AskaEth committed 2026-09-15 01:31:07 +08:00
1 parent 892b9190aa
commit 9c7081b21d
27 files changed
+5467 -2

No files matched your search

+190
View File
@@ -0,0 +1,190 @@
'use strict';
const api = window.clockApi;
const WEEKDAYS = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
const stage = document.getElementById('stage');
const digitalView = document.getElementById('digitalView');
const digitalTime = document.getElementById('digitalTime');
const digitalDate = document.getElementById('digitalDate');
const analogView = document.getElementById('analogView');
const badge = document.getElementById('badge');
const closeBtn = document.getElementById('closeBtn');
const hands = {
hour: document.getElementById('hourHand'),
minute: document.getElementById('minuteHand'),
second: document.getElementById('secondHand')
};
// 主进程下发的走时参数:目标时间 = baseMs + (真实时间 - baseRealMs) × rate
let snapshot = { baseMs: Date.now(), baseRealMs: Date.now(), rate: 1, style: 'digital' };
// 呈现时间层:跳变时由它把「目标时间」缓动过来(数字滚、指针扫都是它的副产品)
const presented = presentedTime.create();
function pad2(value) {
return String(value).padStart(2, '0');
}
function buildDial() {
const ticks = document.getElementById('ticks');
const numerals = document.getElementById('numerals');
const ns = 'http://www.w3.org/2000/svg';
for (let i = 0; i < 60; i += 1) {
const major = i % 5 === 0;
const angle = (i * 6 * Math.PI) / 180;
const outer = 90;
const inner = major ? 78 : 85;
const line = document.createElementNS(ns, 'line');
line.setAttribute('x1', String(Math.sin(angle) * inner));
line.setAttribute('y1', String(-Math.cos(angle) * inner));
line.setAttribute('x2', String(Math.sin(angle) * outer));
line.setAttribute('y2', String(-Math.cos(angle) * outer));
line.setAttribute('class', major ? 'tick tick-major' : 'tick');
ticks.appendChild(line);
}
for (let h = 1; h <= 12; h += 1) {
const angle = (h * 30 * Math.PI) / 180;
const text = document.createElementNS(ns, 'text');
text.setAttribute('x', String(Math.sin(angle) * 62));
text.setAttribute('y', String(-Math.cos(angle) * 62));
text.setAttribute('class', 'numeral');
text.textContent = String(h);
numerals.appendChild(text);
}
}
// 数字时钟用等宽 tabular 字体,字符宽度与字号成正比,所以量一次比例就能按窗口尺寸精确铺满
let fontRatio = null;
function measureFontRatio() {
const previous = digitalTime.style.fontSize;
digitalTime.style.fontSize = '100px';
const rect = digitalTime.getBoundingClientRect();
fontRatio = { width: rect.width / 100, height: rect.height / 100 };
digitalTime.style.fontSize = previous;
}
function layoutDigital() {
if (snapshot.style !== 'digital' || !fontRatio || !fontRatio.width || !fontRatio.height) return;
const box = digitalView.getBoundingClientRect();
const size = Math.min((box.width * 0.9) / fontRatio.width, (box.height * 0.6) / fontRatio.height);
digitalTime.style.fontSize = `${Math.max(12, Math.floor(size))}px`;
}
function renderDigital(ms) {
const date = new Date(ms);
digitalTime.textContent = `${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}`;
digitalDate.textContent = `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())} ${WEEKDAYS[date.getDay()]}`;
}
function renderAnalog(ms) {
const date = new Date(ms);
// 秒针按毫秒连续走,这样在高倍率/负倍率下看起来是平滑扫过而不是一格一格跳
const seconds = date.getSeconds() + date.getMilliseconds() / 1000;
const minutes = date.getMinutes() + seconds / 60;
const hours = (date.getHours() % 12) + minutes / 60;
hands.hour.setAttribute('transform', `rotate(${hours * 30} 0 0)`);
hands.minute.setAttribute('transform', `rotate(${minutes * 6} 0 0)`);
hands.second.setAttribute('transform', `rotate(${seconds * 6} 0 0)`);
}
function updateBadge() {
const rate = snapshot.rate;
if (rate === 1) {
badge.hidden = true;
return;
}
badge.hidden = false;
if (rate === 0) badge.textContent = '暂停';
else if (rate < 0) badge.textContent = `倒转 ×${rate}`;
else badge.textContent = `×${rate}`;
}
function applyStyle() {
const analog = snapshot.style === 'analog';
digitalView.hidden = analog;
analogView.hidden = !analog;
if (!analog) layoutDigital();
}
function frame() {
// 数字与表盘都只从「呈现时间」推导:平时它等于目标时间,跳变后的 0.35 秒里它是缓动中的中间值
const ms = presentedTime.valueAt(presented, Date.now());
if (snapshot.style === 'analog') renderAnalog(ms);
else renderDigital(ms);
// 自检脚本靠这个读数核对「主进程模型 → IPC → 画面」是否一致
document.body.dataset.displayMs = String(Math.round(ms));
document.body.dataset.style = snapshot.style;
requestAnimationFrame(frame);
}
// —— 自定义拖边缘改大小 ——
// 无边框窗口在 Windows 上边缘命中不稳定,所以完全自己做:8 条热区接 pointer 事件,
// 用按下瞬间的窗口 bounds 加鼠标位移算出新 bounds,交给主进程 setBounds。
let resize = null;
function applyResize(screenX, screenY) {
const next = computeBounds(
resize.bounds,
resize.dir,
screenX - resize.startX,
screenY - resize.startY
);
api.setBounds(next);
}
for (const handle of document.querySelectorAll('.rz')) {
handle.addEventListener('pointerdown', (event) => {
if (event.button !== 0) return;
const bounds = api.getBoundsSync();
if (!bounds) return;
resize = { dir: handle.dataset.dir, startX: event.screenX, startY: event.screenY, bounds };
handle.setPointerCapture(event.pointerId);
event.preventDefault();
});
handle.addEventListener('pointermove', (event) => {
if (!resize) return;
applyResize(event.screenX, event.screenY);
});
const stop = (event) => {
if (!resize) return;
resize = null;
try {
handle.releasePointerCapture(event.pointerId);
} catch {
/* 指针已经失效时 release 会抛,忽略即可 */
}
};
handle.addEventListener('pointerup', stop);
handle.addEventListener('pointercancel', stop);
}
closeBtn.addEventListener('click', () => api.closeClockWindow());
// 万一鼠标松开的事件丢了(拖拽中窗口失焦等),不能让下一次移动继续改大小
window.addEventListener('blur', () => {
resize = null;
});
// 主进程每次下发参数都走这里:先让呈现时间层判断这次要不要缓动,再更新画面
function applyState(next) {
presentedTime.accept(presented, Date.now(), next);
snapshot = next;
applyStyle();
updateBadge();
}
api.onState(applyState);
new ResizeObserver(() => layoutDigital()).observe(stage);
buildDial();
measureFontRatio();
api.getState().then((initial) => {
applyState(initial);
frame();
});