Files
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

191 lines
6.7 KiB
JavaScript
Raw Permalink 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 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();
});