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

+243
View File
@@ -0,0 +1,243 @@
'use strict';
const { app, BrowserWindow, ipcMain } = require('electron');
const path = require('node:path');
const clockModel = require('./src/clock-model');
const RENDERER_DIR = path.join(__dirname, 'renderer');
const PRELOAD = path.join(__dirname, 'preload.js');
const SELF_TEST = process.argv.includes('--selftest');
const MIN_SIZE = { width: 260, height: 150 };
const DEFAULT_CLOCK_SIZE = { width: 520, height: 280 };
const state = {
clock: clockModel.create(Date.now()),
style: 'digital',
alwaysOnTop: { clock: true, control: false }
};
let controlWindow = null;
let clockWindow = null;
function isAlive(win) {
return Boolean(win) && !win.isDestroyed();
}
function publicState() {
return {
style: state.style,
baseMs: state.clock.baseMs,
baseRealMs: state.clock.baseRealMs,
rate: state.clock.rate,
lastRate: state.clock.lastRate,
paused: state.clock.rate === 0,
clockWindowOpen: isAlive(clockWindow),
alwaysOnTop: { ...state.alwaysOnTop },
serverNow: Date.now()
};
}
function broadcast() {
const snapshot = publicState();
for (const win of [controlWindow, clockWindow]) {
if (isAlive(win)) win.webContents.send('state', snapshot);
}
}
const WEB_PREFERENCES = {
preload: PRELOAD,
contextIsolation: true,
nodeIntegration: false
};
function createControlWindow() {
controlWindow = new BrowserWindow({
width: 920,
height: 720,
minWidth: 780,
minHeight: 560,
title: 'ClockLab 控制台',
backgroundColor: '#12151c',
autoHideMenuBar: true,
show: false,
webPreferences: WEB_PREFERENCES
});
controlWindow.setAlwaysOnTop(state.alwaysOnTop.control);
controlWindow.loadFile(path.join(RENDERER_DIR, 'control.html'));
controlWindow.once('ready-to-show', () => controlWindow.show());
controlWindow.on('closed', () => {
controlWindow = null;
// 控制窗口是唯一常驻入口,它被关掉就等于用户要退出整个程序(时钟窗口一起走)
if (!SELF_TEST) app.quit();
});
}
function createClockWindow() {
if (isAlive(clockWindow)) return clockWindow;
clockWindow = new BrowserWindow({
width: DEFAULT_CLOCK_SIZE.width,
height: DEFAULT_CLOCK_SIZE.height,
minWidth: MIN_SIZE.width,
minHeight: MIN_SIZE.height,
frame: false,
resizable: true,
movable: true,
maximizable: false,
fullscreenable: false,
backgroundColor: '#05070c',
title: 'ClockLab 时钟',
show: false,
webPreferences: WEB_PREFERENCES
});
// screen-saver 级别:现场投影时希望它压在其他全屏窗口之上,普通 alwaysOnTop 在 Windows 上不够硬
clockWindow.setAlwaysOnTop(state.alwaysOnTop.clock, 'screen-saver');
clockWindow.loadFile(path.join(RENDERER_DIR, 'clock.html'));
clockWindow.once('ready-to-show', () => {
clockWindow.show();
broadcast();
});
clockWindow.on('closed', () => {
clockWindow = null;
broadcast();
});
return clockWindow;
}
function closeClockWindow() {
if (isAlive(clockWindow)) clockWindow.close();
}
// 自定义拖边缘改大小最终落到这里:夹住最小尺寸,避免渲染进程算出 NaN 时把窗口搞坏
function applyBounds(next) {
if (!isAlive(clockWindow)) return null;
const current = clockWindow.getBounds();
const bounds = {
x: Number.isFinite(next.x) ? Math.round(next.x) : current.x,
y: Number.isFinite(next.y) ? Math.round(next.y) : current.y,
width: Math.max(MIN_SIZE.width, Number.isFinite(next.width) ? Math.round(next.width) : current.width),
height: Math.max(MIN_SIZE.height, Number.isFinite(next.height) ? Math.round(next.height) : current.height)
};
clockWindow.setBounds(bounds);
return clockWindow.getBounds();
}
// 所有会改动时钟/窗口的动作都集中在这里:IPC 和自检脚本共用同一条代码路径,
// 自检才不会测到一份「和线上不一样」的逻辑。
const actions = {
setRate(rate) {
const value = Number(rate);
if (Number.isFinite(value)) {
clockModel.setRate(state.clock, Date.now(), value);
broadcast();
}
return publicState();
},
togglePause() {
const now = Date.now();
const target = state.clock.rate === 0 ? state.clock.lastRate || 1 : 0;
clockModel.setRate(state.clock, now, target);
broadcast();
return publicState();
},
fastForward(deltaMs) {
const delta = Number(deltaMs);
if (Number.isFinite(delta)) {
clockModel.shift(state.clock, Date.now(), delta);
broadcast();
}
return publicState();
},
setTime(h, m, s) {
const parts = [Number(h), Number(m), Number(s)];
if (parts.every(Number.isFinite)) {
clockModel.setTimeOfDay(state.clock, Date.now(), parts[0], parts[1], parts[2]);
broadcast();
}
return publicState();
},
setStyle(style) {
if (style === 'digital' || style === 'analog') {
state.style = style;
broadcast();
}
return publicState();
},
setAlwaysOnTop(target, value) {
const key = target === 'control' ? 'control' : 'clock';
state.alwaysOnTop[key] = Boolean(value);
const win = key === 'clock' ? clockWindow : controlWindow;
// 时钟窗口用 screen-saver 级别,普通置顶在 Windows 上会被某些全屏窗口压住
if (isAlive(win)) win.setAlwaysOnTop(state.alwaysOnTop[key], key === 'clock' ? 'screen-saver' : 'normal');
broadcast();
return publicState();
},
toggleClockWindow() {
if (isAlive(clockWindow)) closeClockWindow();
else createClockWindow();
broadcast();
return publicState();
},
closeClockWindow() {
closeClockWindow();
return publicState();
}
};
function registerIpc() {
ipcMain.handle('state:get', () => publicState());
ipcMain.handle('rate:set', (_event, rate) => actions.setRate(rate));
ipcMain.handle('pause:toggle', () => actions.togglePause());
ipcMain.handle('fast-forward', (_event, deltaMs) => actions.fastForward(deltaMs));
ipcMain.handle('time:set', (_event, payload) => actions.setTime(payload?.h, payload?.m, payload?.s));
ipcMain.handle('style:set', (_event, style) => actions.setStyle(style));
ipcMain.handle('always-on-top:set', (_event, payload) => actions.setAlwaysOnTop(payload?.target, payload?.value));
ipcMain.handle('clock-window:toggle', () => actions.toggleClockWindow());
ipcMain.handle('clock-window:close', () => actions.closeClockWindow());
ipcMain.handle('control-window:close', () => {
if (isAlive(controlWindow)) controlWindow.close();
return publicState();
});
ipcMain.on('bounds:get-sync', (event) => {
event.returnValue = isAlive(clockWindow) ? clockWindow.getBounds() : null;
});
ipcMain.on('bounds:set', (_event, bounds) => {
if (bounds && typeof bounds === 'object') applyBounds(bounds);
});
}
app.whenReady().then(() => {
registerIpc();
createControlWindow();
if (SELF_TEST) {
require('./src/selftest')({
app,
state,
clockModel,
actions,
publicState,
applyBounds,
createClockWindow,
getClockWindow: () => clockWindow,
getControlWindow: () => controlWindow
});
}
});
app.on('window-all-closed', () => {
if (!SELF_TEST) app.quit();
});