Files
LoveChengke 82c85b59c1 feat(dist,deploy,website): 交付产物统一归集到 releases/<组件>/<版本>,补齐官网与 Linux 验收
三个打包脚本原先各写各的输出目录,现在一律直接落在仓库根 releases/<组件>/<版本>/,分层固定为安装包/免安装/构建中间;dist-win 用 --config.directories.output 覆盖 electron-builder 的目录后再整理,显式传入时不覆盖。插件侧顺手修掉一个既有 bug:原先 rmSync 整个组件目录,重打一次就把上一版的留档抹掉,现在只清当前版本目录。verify-packaged 的 --exe 默认值跟着改到新落点。

deploy/verify-linux.sh 修掉管道在 pipefail 下的 SIGPIPE 误判(命中被算成失败);CI workflow 的出包与上传路径同步;electron-builder.yml 显式指定安装包图标。

website/ 是产品官网(纯静态、无构建步骤,不进 pnpm workspace),含 beUI 动效 token 与实机留档图;客户端冒烟相应加了采集留档图用的外观开关。
2026-10-06 15:31:38 +08:00

304 lines
12 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 动效层 —— 每一条曲线、每一个弹簧参数都取自 beUI(beui.dev)的源码:
*
* lib/ease.ts 共享 token(EASE_OUT / SPRING_PRESS / …)
* components/motion/dynamic-island 壳 spring 用 Apple 的 duration+bounce 形式
* components/motion/text-animation TextReveal:逐字上浮 + 去模糊
* components/motion/number NumberTicker:数字竖排滚轮
* components/motion/shared-layout-bg 悬停胶囊在条目之间滑移
* components/motion/range-slider 位置用 SPRING_GLIDE,拖拽把手 scaleY 用 SPRING_BOUNCY
* components/motion/scroll-animation 阅读进度条,PROGRESS_SPRING 缓跟
* components/motion/button whileTap 0.93 / whileHover 1.02
*
* 这里只有引擎,没有页面逻辑;页面逻辑在 main.js 里。
*/
(() => {
'use strict';
/* ═══════════════════════════════ 曲线 ═══════════════════════════════════ */
/** 进场与退场:立刻响应,然后安静地落位 */
const EASE_OUT = 'cubic-bezier(0.16, 1, 0.3, 1)';
/** 已经在屏幕上的东西开始移动 */
const EASE_IN_OUT = 'cubic-bezier(0.77, 0, 0.175, 1)';
/** 抽屉 */
const EASE_DRAWER = 'cubic-bezier(0.32, 0.72, 0, 1)';
/* ═══════════════════════════════ 弹簧 ═══════════════════════════════════ */
/** 按钮等可按压表面:快、有分量 */
const SPRING_PRESS = { stiffness: 500, damping: 30, mass: 0.6 };
/** 控件内部文字 / 图标交换位置 */
const SPRING_SWAP = { stiffness: 460, damping: 30, mass: 0.55 };
/** 由指针唤出的浮层 */
const SPRING_PANEL = { stiffness: 420, damping: 40, mass: 0.5 };
/** 共享布局滑移:胶囊、指示器、面板换位置 */
const SPRING_LAYOUT = { stiffness: 360, damping: 32, mass: 0.6 };
/** 装饰性的鼠标跟随(磁吸、倾斜、Dock) */
const SPRING_MOUSE = { stiffness: 200, damping: 15, mass: 0.3 };
/** 拖拽把手与填充:临界阻尼,跟手且不会在两端回弹 */
const SPRING_GLIDE = { stiffness: 700, damping: 50, mass: 0.5 };
/** 范围滑块把手的抓取反馈(只做 scaleY) */
const SPRING_BOUNCY = { stiffness: 500, damping: 14, mass: 0.7 };
/** 阅读进度条:故意比 UI 弹簧松,让它跟着滚动慢慢追上来 */
const SPRING_PROGRESS = { stiffness: 120, damping: 30, mass: 0.6 };
/** 标题逐字上浮(TextReveal 的 DEFAULT_SPRING) */
const SPRING_TEXT = { stiffness: 140, damping: 26, mass: 1.2 };
/* ═════════════════════════ 时长口径(beUI motion guide) ═════════════════════════
按压反馈 100–160ms · tooltip 125–200 · 下拉 150–250 · 弹窗抽屉 200–500
界面动效默认 < 300ms;更长的动效只属于讲解型演示。 */
const DURATION = {
press: 0.14,
swap: 0.2,
reveal: 0.22,
islandShell: 0.8,
islandContent: 0.8,
islandExit: 0.08,
number: 0.9,
numberStagger: 0.04,
textStagger: 0.03,
};
/* ═══════════════════════════ duration + bounce ═══════════════════════════
motion 的 Apple 式弹簧写法:给一个「感知时长」和一个回弹量,反解出刚度。
bounce = 0 无回弹(临界阻尼),bounce = 1 最弹;阻尼比 ζ = 1 - bounce。
做法是拿单位阶跃响应去二分刚度,让它在 duration 秒内落到静止 —— 与
motion 的 springFromDuration 同一套语义(因此感知时长与位移距离无关)。 */
const REST_DELTA = 0.01;
const REST_SPEED = 0.01;
const SOLVE_STEP = 1 / 480;
/** 运行时积分用的固定子步,与求解器同口径 */
const INTERNAL_STEP_MS = 1000 / 480;
/** 单帧最多补多少个子步,防止切回标签页时一次算几千步 */
const MAX_SUBSTEPS = 96;
function settleTime(stiffness, damping, mass) {
let x = 0;
let velocity = 0;
for (let t = 0; t < 6; t += SOLVE_STEP) {
const acceleration = (-stiffness * (x - 1) - damping * velocity) / mass;
velocity += acceleration * SOLVE_STEP;
x += velocity * SOLVE_STEP;
if (Math.abs(x - 1) < REST_DELTA && Math.abs(velocity) < REST_SPEED) return t;
}
return 6;
}
const durationCache = new Map();
function springFromDuration(duration, bounce, mass = 1) {
const key = `${duration}|${bounce}|${mass}`;
const cached = durationCache.get(key);
if (cached) return cached;
const zeta = Math.max(0.05, Math.min(1, 1 - bounce));
// 刚度越大落位越快,settleTime 对它单调递减 —— 二分找出刚好在 duration 落位的那个
let low = 1;
let high = 4000;
for (let i = 0; i < 40; i += 1) {
const stiffness = (low + high) / 2;
const damping = 2 * zeta * Math.sqrt(stiffness * mass);
if (settleTime(stiffness, damping, mass) > duration) low = stiffness;
else high = stiffness;
}
const stiffness = (low + high) / 2;
const result = { stiffness, damping: 2 * zeta * Math.sqrt(stiffness * mass), mass };
durationCache.set(key, result);
return result;
}
/** 把 {stiffness,damping,mass} 或 {duration,bounce} 统一成系数 */
function resolveConfig(config) {
if (config.duration !== undefined) {
return springFromDuration(config.duration, config.bounce ?? 0, config.mass ?? 1);
}
return { stiffness: config.stiffness, damping: config.damping, mass: config.mass ?? 1 };
}
/* ═══════════════════════════ 共享帧循环 ═══════════════════════════
全站一个 rAF:所有弹簧都挂在这上面,谁在动就推进谁,都停了就整条循环歇着。
可见性每帧现读,不缓存 —— 缓存过的标记一旦翻不回来,动画就永久停住。 */
const running = new Set();
let frameId = 0;
let lastTime = 0;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const reduceMotion = () => reduceQuery.matches;
function frame(time) {
const delta = lastTime ? Math.min(64, time - lastTime) : 16;
lastTime = time;
for (const spring of [...running]) {
if (!spring.step(delta)) running.delete(spring);
}
if (running.size > 0) {
frameId = requestAnimationFrame(frame);
} else {
frameId = 0;
lastTime = 0;
}
}
function wake() {
if (!frameId) frameId = requestAnimationFrame(frame);
}
/* ═══════════════════════════════ 弹簧 ═══════════════════════════════ */
class Spring {
/**
* @param {number} initial 初值
* @param {object} config {stiffness,damping,mass} 或 {duration,bounce}
* @param {(value:number)=>void} onUpdate 每帧回调
* @param {{restRatio?:number, restFloor?:number}} [rest] 静止阈值
*/
constructor(initial, config, onUpdate, rest = {}) {
const resolved = resolveConfig(config);
this.stiffness = resolved.stiffness;
this.damping = resolved.damping;
this.mass = resolved.mass;
// duration+bounce 那套是按「本次位移的 1%」判定静止的,这里跟着同口径缩放 ——
// 用绝对阈值的话,同一组参数走 20px 和走 200px 的观感时长会不一样。
this.restRatio = rest.restRatio ?? REST_DELTA;
this.restFloor = rest.restFloor ?? 1e-4;
this.restDelta = this.restFloor;
this.restSpeed = this.restFloor;
this.value = initial;
this.target = initial;
this.velocity = 0;
this.onUpdate = onUpdate;
}
get settled() {
return this.value === this.target && this.velocity === 0;
}
/** 换目标。保留当前速度 —— 真实弹簧就是这样,中途改向不会顿一下。 */
set(target, options = {}) {
if (options.velocity !== undefined) this.velocity = options.velocity;
if (target === this.target && this.settled) return;
this.target = target;
if (reduceMotion() || options.instant) {
this.jump(target);
return;
}
const travel = Math.abs(target - this.value);
this.restDelta = Math.max(this.restFloor, travel * this.restRatio);
this.restSpeed = this.restDelta;
running.add(this);
wake();
}
/** 直接落位,不做动画 */
jump(target) {
this.target = target;
this.value = target;
this.velocity = 0;
running.delete(this);
this.onUpdate?.(this.value, this);
}
/** 把当前值刷一次到 DOM。构造之后、或 set() 因为目标没变而早退时,靠它把首帧对上。 */
paint() {
this.onUpdate?.(this.value, this);
}
step(deltaMs) {
// 定步长积分:显式欧拉在 60/120Hz 这种大步长下会明显耗散,同一组参数的
// 观感时长会随屏幕刷新率变(0.8s 的壳在 120Hz 下会 0.48s 就停)。
// 这里把它拆成 1/480s 的固定子步,几时落位只由参数决定,与刷新率无关。
const sub = INTERNAL_STEP_MS;
let remaining = Math.min(deltaMs, sub * MAX_SUBSTEPS);
let moving = false;
while (remaining > 0) {
const dt = Math.min(sub, remaining) / 1000;
remaining -= sub;
moving = this.#integrate(dt) || moving;
}
return moving;
}
#integrate(dt) {
const distance = this.target - this.value;
if (distance === 0 && Math.abs(this.velocity) < this.restSpeed) {
this.value = this.target;
this.velocity = 0;
this.onUpdate?.(this.value, this);
return false;
}
// 胡克定律 + 阻尼:a = (k·(target − value) − c·v) / m
const acceleration = (this.stiffness * distance - this.damping * this.velocity) / this.mass;
this.velocity += acceleration * dt;
this.value += this.velocity * dt;
if (Math.abs(this.target - this.value) < this.restDelta && Math.abs(this.velocity) < this.restSpeed) {
this.value = this.target;
this.velocity = 0;
this.onUpdate?.(this.value, this);
return false;
}
if (!Number.isFinite(this.value) || !Number.isFinite(this.velocity)) {
this.value = this.target;
this.velocity = 0;
this.onUpdate?.(this.value, this);
return false;
}
this.onUpdate?.(this.value, this);
return true;
}
}
/* ═══════════════════════════ 通用小工具 ═══════════════════════════ */
const round2 = (value) => Math.round(value * 100) / 100;
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
/** 指针能悬停吗 —— 触摸设备上不要挂 hover 态 */
const hoverCapable = () => window.matchMedia('(hover: hover) and (pointer: fine)').matches;
function setTransition(el, properties, duration, ease = EASE_OUT, delay = 0) {
el.style.transition = properties
.map((property) => `${property} ${duration}s ${ease} ${delay ? `${delay}s` : '0s'}`)
.join(', ');
}
/** 动画重定向:先落 initial,下一帧再说 —— 保证浏览器真的重绘一次 */
function nextFrame(callback) {
requestAnimationFrame(() => requestAnimationFrame(callback));
}
window.CHMotion = {
EASE_OUT,
EASE_IN_OUT,
EASE_DRAWER,
SPRING_PRESS,
SPRING_SWAP,
SPRING_PANEL,
SPRING_LAYOUT,
SPRING_MOUSE,
SPRING_GLIDE,
SPRING_BOUNCY,
SPRING_PROGRESS,
SPRING_TEXT,
DURATION,
Spring,
springFromDuration,
reduceMotion,
hoverCapable,
round2,
clamp,
setTransition,
nextFrame,
};
})();