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:
1 parent
892b9190aa
commit
9c7081b21d
27 files changed
+5467
-2
No files matched your search
@@ -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();
|
||||
});
|
||||
Reference in new issue
Block a user