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,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();
|
||||
});
|
||||
Reference in new issue
Block a user