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

+214
View File
@@ -0,0 +1,214 @@
:root {
--bg: #05070c;
--fg: #f2f6ff;
--accent: #4fd1c5;
--muted: #7d8aa5;
--handle: 10px;
--corner: 18px;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
height: 100%;
overflow: hidden;
background: var(--bg);
color: var(--fg);
user-select: none;
cursor: default;
font-family: 'Segoe UI', 'Microsoft YaHei', sans-serif;
}
/* 拖动区:无边框窗口靠它实现「按住任意空白处移动窗口」 */
.stage {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: radial-gradient(120% 140% at 50% 0%, #131a29 0%, var(--bg) 70%);
-webkit-app-region: drag;
}
.view {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.view[hidden] {
display: none;
}
.digital-time {
font-family: Consolas, 'Cascadia Mono', 'DejaVu Sans Mono', monospace;
font-variant-numeric: tabular-nums;
font-weight: 600;
line-height: 1;
letter-spacing: 0.02em;
color: var(--fg);
text-shadow: 0 0 28px rgba(79, 209, 197, 0.35);
white-space: nowrap;
}
.digital-date {
margin-top: 0.7em;
/* 用 vmin 跟着窗口缩放,投到大屏时日期才不会小得看不见 */
font-size: clamp(13px, 5.5vmin, 64px);
letter-spacing: 0.18em;
color: var(--muted);
white-space: nowrap;
}
.analog-view {
padding: 10px;
}
.dial {
height: 100%;
width: 100%;
max-width: 100%;
max-height: 100%;
}
.dial-face {
fill: #0b1020;
stroke: #26314a;
stroke-width: 2;
}
.tick {
stroke: #46577a;
stroke-width: 2;
stroke-linecap: round;
}
.tick-major {
stroke: #8fa4cc;
stroke-width: 4;
}
.numeral {
fill: #8fa4cc;
font-family: Consolas, 'DejaVu Sans Mono', monospace;
font-size: 18px;
text-anchor: middle;
dominant-baseline: central;
}
.hand {
stroke-linecap: round;
}
.hand-hour {
stroke: #e6ecff;
stroke-width: 7;
}
.hand-minute {
stroke: #c3cfe8;
stroke-width: 4.5;
}
.hand-second {
stroke: var(--accent);
stroke-width: 2;
}
.hand-cap {
fill: var(--accent);
}
.badge {
position: absolute;
top: 10px;
left: 12px;
padding: 3px 10px;
border-radius: 999px;
font-size: 13px;
letter-spacing: 0.06em;
color: #0b1020;
background: var(--accent);
opacity: 0.92;
-webkit-app-region: no-drag;
}
.badge[hidden] {
display: none;
}
.close-btn {
position: absolute;
top: 8px;
right: 8px;
width: 26px;
height: 26px;
line-height: 1;
font-size: 18px;
border: 0;
border-radius: 8px;
color: #9fb0cc;
background: rgba(255, 255, 255, 0.06);
cursor: pointer;
opacity: 0;
transition: opacity 0.15s ease;
/* 关键:按钮必须退出拖动区,否则在无边框窗口里点不动 */
-webkit-app-region: no-drag;
}
.stage:hover .close-btn {
opacity: 1;
}
.close-btn:hover {
color: #fff;
background: #e0555a;
}
/* 自定义改大小的边缘热区:放在 stage 之外,天然不属于拖动区,鼠标事件能正常到达 */
.rz {
position: fixed;
z-index: 20;
-webkit-app-region: no-drag;
}
.rz-n,
.rz-s {
left: var(--corner);
right: var(--corner);
height: var(--handle);
cursor: ns-resize;
}
.rz-w,
.rz-e {
top: var(--corner);
bottom: var(--corner);
width: var(--handle);
cursor: ew-resize;
}
.rz-n { top: 0; }
.rz-s { bottom: 0; }
.rz-w { left: 0; }
.rz-e { right: 0; }
.rz-nw,
.rz-ne,
.rz-sw,
.rz-se {
width: var(--corner);
height: var(--corner);
}
.rz-nw { top: 0; left: 0; cursor: nwse-resize; }
.rz-se { bottom: 0; right: 0; cursor: nwse-resize; }
.rz-ne { top: 0; right: 0; cursor: nesw-resize; }
.rz-sw { bottom: 0; left: 0; cursor: nesw-resize; }
+47
View File
@@ -0,0 +1,47 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; object-src 'none'" />
<title>ClockLab 时钟</title>
<link rel="stylesheet" href="clock.css" />
</head>
<body>
<!-- stage 整体是拖动区;里面的按钮/角标再单独声明 no-drag,避免被拖动区吞掉点击 -->
<main id="stage" class="stage">
<div id="digitalView" class="view digital-view">
<div id="digitalTime" class="digital-time">00:00:00</div>
<div id="digitalDate" class="digital-date"></div>
</div>
<div id="analogView" class="view analog-view" hidden>
<svg id="dial" class="dial" viewBox="-100 -100 200 200" aria-hidden="true">
<circle class="dial-face" cx="0" cy="0" r="94"></circle>
<g id="ticks"></g>
<g id="numerals"></g>
<line id="hourHand" class="hand hand-hour" x1="0" y1="10" x2="0" y2="-50"></line>
<line id="minuteHand" class="hand hand-minute" x1="0" y1="14" x2="0" y2="-76"></line>
<line id="secondHand" class="hand hand-second" x1="0" y1="20" x2="0" y2="-86"></line>
<circle class="hand-cap" cx="0" cy="0" r="6"></circle>
</svg>
</div>
<div id="badge" class="badge" hidden></div>
<button id="closeBtn" class="close-btn" title="关闭时钟窗口">×</button>
</main>
<div class="rz rz-n" data-dir="n"></div>
<div class="rz rz-s" data-dir="s"></div>
<div class="rz rz-w" data-dir="w"></div>
<div class="rz rz-e" data-dir="e"></div>
<div class="rz rz-nw" data-dir="nw"></div>
<div class="rz rz-ne" data-dir="ne"></div>
<div class="rz rz-sw" data-dir="sw"></div>
<div class="rz rz-se" data-dir="se"></div>
<script src="resize.js"></script>
<script src="presented-time.js"></script>
<script src="clock.js"></script>
</body>
</html>
+190
View File
@@ -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();
});
+297
View File
@@ -0,0 +1,297 @@
:root {
--bg: #12151c;
--panel: #1a1f2b;
--panel-2: #222838;
--line: #2c3446;
--fg: #e8edf8;
--muted: #8b98b4;
--accent: #4fd1c5;
--accent-dark: #2aa79c;
--warn: #e0a355;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 18px 20px 26px;
min-height: 100vh;
background: var(--bg);
color: var(--fg);
font-family: 'Segoe UI', 'Microsoft YaHei', sans-serif;
font-size: 14px;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
padding: 14px 18px;
border: 1px solid var(--line);
border-radius: 14px;
background: linear-gradient(180deg, #1d2432, #171c27);
}
.brand {
display: flex;
align-items: center;
gap: 12px;
}
.dot {
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 14px var(--accent);
}
h1 {
margin: 0;
font-size: 17px;
font-weight: 600;
letter-spacing: 0.02em;
}
.sub {
margin: 3px 0 0;
font-size: 12px;
color: var(--muted);
}
.readout {
text-align: right;
}
.readout-time {
font-family: Consolas, 'Cascadia Mono', monospace;
font-variant-numeric: tabular-nums;
font-size: 30px;
line-height: 1.1;
color: var(--accent);
}
.readout-meta {
font-size: 12px;
color: var(--muted);
}
.grid {
margin-top: 16px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
align-items: start;
}
.card {
padding: 16px 18px 18px;
border: 1px solid var(--line);
border-radius: 14px;
background: var(--panel);
}
.wide-card {
grid-column: 1 / -1;
}
h2 {
margin: 0 0 14px;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--accent);
}
.row {
display: flex;
align-items: center;
gap: 10px;
}
.row + .row {
margin-top: 12px;
}
.row.wrap {
flex-wrap: wrap;
}
.stack-tight {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.field + .field {
margin-top: 18px;
padding-top: 16px;
border-top: 1px dashed var(--line);
}
.field-label {
margin-bottom: 10px;
font-size: 12px;
color: var(--muted);
}
.btn {
padding: 8px 14px;
border: 1px solid var(--line);
border-radius: 9px;
background: var(--panel-2);
color: var(--fg);
font-size: 13px;
font-family: inherit;
cursor: pointer;
transition: background 0.12s ease, border-color 0.12s ease, transform 0.06s ease;
}
.btn:hover {
background: #2a3244;
border-color: #3b465e;
}
.btn:active {
transform: translateY(1px);
}
.btn-primary {
background: var(--accent-dark);
border-color: var(--accent-dark);
color: #06231f;
font-weight: 600;
}
.btn-primary:hover {
background: var(--accent);
border-color: var(--accent);
}
.btn-primary.is-on {
background: #3a2a33;
border-color: #6b3a44;
color: #ffb3bd;
}
.btn-primary.is-on:hover {
background: #4a333e;
}
.btn-warn {
border-color: #6b5326;
color: #f0c37a;
}
/* 暂停中要一眼看出来,否则用户会以为时钟坏了 */
.btn-warn.is-on {
background: #3b2f1a;
border-color: var(--warn);
color: #ffd79a;
font-weight: 600;
}
.btn-ghost {
background: transparent;
color: var(--muted);
}
.btn-chip {
padding: 6px 11px;
font-size: 12.5px;
}
.btn.seg {
flex: 1;
padding: 10px 14px;
}
.btn.seg.is-active {
background: var(--accent-dark);
border-color: var(--accent);
color: #06231f;
font-weight: 600;
}
.segmented {
display: flex;
gap: 10px;
}
.wide {
width: 100%;
padding: 11px 14px;
font-size: 14px;
}
.input {
padding: 8px 10px;
border: 1px solid var(--line);
border-radius: 9px;
background: #0f131b;
color: var(--fg);
font-family: Consolas, 'Cascadia Mono', monospace;
font-size: 15px;
font-variant-numeric: tabular-nums;
}
.input:focus {
outline: none;
border-color: var(--accent-dark);
}
.num {
width: 62px;
text-align: center;
}
.wide-num {
width: 96px;
}
.time-inputs {
display: flex;
align-items: center;
gap: 4px;
}
.colon {
color: var(--muted);
font-size: 16px;
}
.check {
display: flex;
align-items: center;
gap: 9px;
cursor: pointer;
color: var(--fg);
}
.check input {
width: 16px;
height: 16px;
accent-color: var(--accent-dark);
cursor: pointer;
}
.hint {
margin: 14px 0 0;
font-size: 12px;
line-height: 1.55;
color: var(--muted);
}
.presets {
margin-top: 10px;
}
@media (max-width: 820px) {
.grid {
grid-template-columns: 1fr;
}
}
+105
View File
@@ -0,0 +1,105 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; object-src 'none'" />
<title>ClockLab 控制台</title>
<link rel="stylesheet" href="control.css" />
</head>
<body>
<header class="topbar">
<div class="brand">
<span class="dot"></span>
<div>
<h1>ClockLab 时钟控制台</h1>
<p class="sub">显示时间 = 基准时间 + 真实经过时间 × 倍率</p>
</div>
</div>
<div class="readout">
<div id="readoutTime" class="readout-time">--:--:--</div>
<div id="readoutMeta" class="readout-meta">连接中…</div>
</div>
</header>
<main class="grid">
<section class="card">
<h2>时钟窗口</h2>
<div class="row">
<button id="toggleClockWindow" class="btn btn-primary wide">打开时钟窗口</button>
</div>
<div class="row stack-tight">
<label class="check">
<input id="aotClock" type="checkbox" checked />
<span>时钟窗口始终置顶(默认开)</span>
</label>
<label class="check">
<input id="aotControl" type="checkbox" />
<span>控制窗口始终置顶(默认关)</span>
</label>
</div>
<p class="hint">关掉控制窗口即退出整个程序,时钟窗口一并关闭。</p>
</section>
<section class="card">
<h2>显示样式</h2>
<div class="segmented" id="styleGroup">
<button class="btn seg" data-style="digital">纯数字</button>
<button class="btn seg" data-style="analog">圆形时钟</button>
</div>
<p class="hint">切换立即生效,不需要重开时钟窗口。</p>
</section>
<section class="card wide-card">
<h2>时间与流速</h2>
<div class="field">
<div class="field-label">设定时间(24 小时制)</div>
<div class="row">
<div class="time-inputs">
<input id="hh" class="input num" type="number" min="0" max="23" value="12" />
<span class="colon">:</span>
<input id="mm" class="input num" type="number" min="0" max="59" value="0" />
<span class="colon">:</span>
<input id="ss" class="input num" type="number" min="0" max="59" value="0" />
</div>
<button id="applyTime" class="btn">应用</button>
<button id="fillNow" class="btn btn-ghost">填入当前显示时间</button>
</div>
</div>
<div class="field">
<div class="field-label">流速倍率(可正、可负、可 0、可小数)</div>
<div class="row">
<input id="rate" class="input num wide-num" type="number" step="0.1" value="1" />
<button id="applyRate" class="btn">应用</button>
<button id="togglePause" class="btn btn-warn">暂停</button>
</div>
<div class="row wrap presets">
<button class="btn btn-chip" data-rate="-1">-1 倒转</button>
<button class="btn btn-chip" data-rate="-2">-2</button>
<button class="btn btn-chip" data-rate="0">0 暂停</button>
<button class="btn btn-chip" data-rate="0.5">0.5</button>
<button class="btn btn-chip" data-rate="1">1 正常</button>
<button class="btn btn-chip" data-rate="2">2</button>
<button class="btn btn-chip" data-rate="10">10</button>
<button class="btn btn-chip" data-rate="60">60</button>
</div>
<p class="hint">「暂停 / 继续」按钮等价于倍率 0 / 回到暂停前的那个倍率。倍率为负时时钟会一直往回走,不是卡住。</p>
</div>
<div class="field">
<div class="field-label">快进 / 快退(按当前显示时间整体平移,可与任意倍率叠加)</div>
<div class="row wrap">
<button class="btn btn-chip" data-delta="5000">+5 秒</button>
<button class="btn btn-chip" data-delta="60000">+1 分钟</button>
<button class="btn btn-chip" data-delta="300000">+5 分钟</button>
<button class="btn btn-chip" data-delta="600000">+10 分钟</button>
<button class="btn btn-chip" data-delta="1800000">+30 分钟</button>
</div>
</div>
</section>
</main>
<script src="control.js"></script>
</body>
</html>
+145
View File
@@ -0,0 +1,145 @@
'use strict';
const api = window.clockApi;
const els = {
readoutTime: document.getElementById('readoutTime'),
readoutMeta: document.getElementById('readoutMeta'),
toggleClockWindow: document.getElementById('toggleClockWindow'),
aotClock: document.getElementById('aotClock'),
aotControl: document.getElementById('aotControl'),
styleGroup: document.getElementById('styleGroup'),
hh: document.getElementById('hh'),
mm: document.getElementById('mm'),
ss: document.getElementById('ss'),
applyTime: document.getElementById('applyTime'),
fillNow: document.getElementById('fillNow'),
rate: document.getElementById('rate'),
applyRate: document.getElementById('applyRate'),
togglePause: document.getElementById('togglePause')
};
let snapshot = { baseMs: Date.now(), baseRealMs: Date.now(), rate: 1, style: 'digital', paused: false, clockWindowOpen: false };
function pad2(value) {
return String(value).padStart(2, '0');
}
function displayMs(now) {
return snapshot.baseMs + (now - snapshot.baseRealMs) * snapshot.rate;
}
function clampInt(value, min, max, fallback) {
const n = Number.parseInt(value, 10);
if (!Number.isFinite(n)) return fallback;
return Math.min(max, Math.max(min, n));
}
function renderReadout() {
const ms = displayMs(Date.now());
const date = new Date(ms);
els.readoutTime.textContent = `${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}`;
const rateText = snapshot.rate === 0 ? '暂停' : `×${snapshot.rate}`;
const runText = snapshot.rate === 0 ? '已暂停' : snapshot.rate < 0 ? '倒转中' : '运行中';
els.readoutMeta.textContent = `${rateText} · ${runText} · 时钟窗口${snapshot.clockWindowOpen ? '已打开' : '已关闭'}`;
requestAnimationFrame(renderReadout);
}
function syncControls() {
els.toggleClockWindow.textContent = snapshot.clockWindowOpen ? '关闭时钟窗口' : '打开时钟窗口';
els.toggleClockWindow.classList.toggle('is-on', snapshot.clockWindowOpen);
els.aotClock.checked = Boolean(snapshot.alwaysOnTop.clock);
els.aotControl.checked = Boolean(snapshot.alwaysOnTop.control);
for (const button of els.styleGroup.querySelectorAll('.seg')) {
button.classList.toggle('is-active', button.dataset.style === snapshot.style);
}
// 用户正在输入时不要抢走他的光标和内容
if (document.activeElement !== els.rate) els.rate.value = String(snapshot.rate);
if (snapshot.rate === 0) {
els.togglePause.textContent = `继续(回到 ×${snapshot.lastRate})`;
els.togglePause.classList.add('is-on');
} else {
els.togglePause.textContent = '暂停';
els.togglePause.classList.remove('is-on');
}
}
function accept(next) {
if (next) {
snapshot = next;
syncControls();
}
}
els.toggleClockWindow.addEventListener('click', async () => accept(await api.toggleClockWindow()));
els.aotClock.addEventListener('change', async () => accept(await api.setAlwaysOnTop('clock', els.aotClock.checked)));
els.aotControl.addEventListener('change', async () => accept(await api.setAlwaysOnTop('control', els.aotControl.checked)));
for (const button of els.styleGroup.querySelectorAll('.seg')) {
button.addEventListener('click', async () => accept(await api.setStyle(button.dataset.style)));
}
els.applyTime.addEventListener('click', async () => {
const h = clampInt(els.hh.value, 0, 23, 0);
const m = clampInt(els.mm.value, 0, 59, 0);
const s = clampInt(els.ss.value, 0, 59, 0);
els.hh.value = String(h);
els.mm.value = String(m);
els.ss.value = String(s);
accept(await api.setTime(h, m, s));
});
els.fillNow.addEventListener('click', () => {
// 三格都要补零:`String(0)` 是 "0",而输入框按 HH:MM:SS 解析,
// 上午 9 点前会填出 "0:39:27" 这种非法值,点应用时解析失败。
const date = new Date(displayMs(Date.now()));
els.hh.value = pad2(date.getHours());
els.mm.value = pad2(date.getMinutes());
els.ss.value = pad2(date.getSeconds());
});
function applyRateInput() {
const value = Number(els.rate.value);
if (!Number.isFinite(value)) {
els.rate.value = String(snapshot.rate);
return;
}
api.setRate(value).then(accept);
}
els.applyRate.addEventListener('click', applyRateInput);
els.rate.addEventListener('keydown', (event) => {
if (event.key === 'Enter') applyRateInput();
});
els.togglePause.addEventListener('click', async () => accept(await api.togglePause()));
document.body.addEventListener('click', (event) => {
const rate = event.target.dataset?.rate;
if (rate !== undefined) {
api.setRate(Number(rate)).then(accept);
return;
}
const delta = event.target.dataset?.delta;
if (delta !== undefined) {
api.fastForward(Number(delta)).then(accept);
}
});
api.onState((next) => {
snapshot = next;
syncControls();
});
api.getState().then((initial) => {
snapshot = initial;
syncControls();
renderReadout();
});
+74
View File
@@ -0,0 +1,74 @@
'use strict';
// 呈现时间层:主进程给的是「目标时间」,画面用的是「呈现时间」。
// 平时两者相等;只有在发生一次性跳变(快进、设定时间)时,才用一小段时间把呈现时间缓动到新目标,
// 于是数字会自己「滚」过去、指针会「扫」过去 —— 数字和表盘都只从这一个值推导,不必各写一套动画。
//
// 之所以动画「时间」而不是动画「字符 / 角度」:跨 12 点、跨 0 点时角度公式照旧,
// 不会出现 359°→1° 倒着转一圈;老式里程表那种数字观感也只是同一个值的副产品。
//
// 拆成独立文件是为了能在 node 里直接跑用例(同 renderer/resize.js 的做法)。
const JUMP_THRESHOLD_MS = 1500; // 一步小于这个幅度就直接跳:正常走时每秒都在变,不该每秒动一次
const EASE_MS = 350; // 缓动时长,够看清「滚」的过程,又不至于拖到下一次操作
function targetAt(snapshot, now) {
return snapshot.baseMs + (now - snapshot.baseRealMs) * snapshot.rate;
}
// 两次参数之间的目标断点:在同一个真实时刻上,分别用新旧参数算时间再相减。
// 取新参数的基准时刻来比,是因为改倍率时模型先把当前显示时间固化进了基准,
// 两个公式在这一刻必然相等 —— 「改变速度」不会被误判成「跳变」。
function jumpBetween(previous, next) {
if (!previous) return 0;
const at = next.baseRealMs;
return targetAt(next, at) - targetAt(previous, at);
}
function create() {
return { snapshot: null, offsetMs: 0, startMs: 0 };
}
// 尚未消化的偏移:缓动起点处的差值按三次缓出衰减到 0,越小说明越接近目标
function pendingMs(layer, now) {
const t = (now - layer.startMs) / EASE_MS;
if (t >= 1) return 0; // 到点就彻底归零,不给浮点留残余(否则数字永远差几百毫秒)
if (t <= 0) return layer.offsetMs;
const left = 1 - t;
return layer.offsetMs * left * left * left;
}
// 收到新的走时参数时调用:决定这一次是「直接跳」还是「缓动过去」
function accept(layer, now, snapshot) {
// 只在「目标时间确实断了一截」时才缓动,所以:
// · 幅度太小(正常走时、+1 秒)直跳 —— 否则每秒都在滚,反而看不清时间;
// · rate 0(暂停)直跳 —— 暂停画面必须停在暂停那一刻的准确时间上,
// 让缓动挂在那里的话,暂停期间显示的就是一个走不完的中间值(看着像时间不对)。
const jump = layer.snapshot ? jumpBetween(layer.snapshot, snapshot) : 0;
const animate = layer.snapshot !== null && snapshot.rate !== 0 && Math.abs(jump) > JUMP_THRESHOLD_MS;
if (animate) {
// 从「当前呈现值」重新出发:缓动没结束又来一次跳变时,用旧目标减去尚未消化的偏移反推回去,
// 不能从跳变前的旧值重来,否则画面会先弹回去再滚一次。
const presentedNow = targetAt(layer.snapshot, now) - pendingMs(layer, now);
layer.offsetMs = targetAt(snapshot, now) - presentedNow;
} else {
layer.offsetMs = 0;
}
layer.snapshot = snapshot;
layer.startMs = now;
}
// 每帧调用:这一刻该显示的时间
function valueAt(layer, now) {
return targetAt(layer.snapshot, now) - pendingMs(layer, now);
}
// 渲染进程里这几个函数要能被 clock.js 直接用,所以除了 CommonJS 导出,还挂成一个命名空间
// (同 resize.js 的双向可用做法:浏览器读全局,node 测试读 require)
const presentedTime = { create, accept, valueAt, targetAt, jumpBetween, JUMP_THRESHOLD_MS, EASE_MS };
if (typeof module !== 'undefined' && module.exports) {
module.exports = presentedTime;
}
+42
View File
@@ -0,0 +1,42 @@
'use strict';
// 拖边缘改大小的纯计算部分单独放一个文件:
// 这段几何运算是最容易出错、但在无边框窗口里只能靠真鼠标才能触发的地方,
// 拆出来之后可以在 node 里直接跑用例,不必等到 Windows 上肉眼试。
const MIN_SIZE = { width: 260, height: 150 };
function computeBounds(start, dir, dx, dy, min) {
const limit = min || MIN_SIZE;
let x = start.x;
let y = start.y;
let width = start.width;
let height = start.height;
if (dir.includes('e')) width += dx;
if (dir.includes('s')) height += dy;
if (dir.includes('w')) {
width -= dx;
x += dx;
}
if (dir.includes('n')) {
height -= dy;
y += dy;
}
// 顶到最小尺寸后继续拖,被拖的那条边要停住,而不是把整个窗口推走
if (width < limit.width) {
if (dir.includes('w')) x = start.x + start.width - limit.width;
width = limit.width;
}
if (height < limit.height) {
if (dir.includes('n')) y = start.y + start.height - limit.height;
height = limit.height;
}
return { x: Math.round(x), y: Math.round(y), width: Math.round(width), height: Math.round(height) };
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = { computeBounds, MIN_SIZE };
}