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