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