Files
ClassHelper/packages/desktop-client/src/renderer/composables/motion.ts
T
LoveChengke ce9bed73f4 feat(shared,web-admin,desktop-client,build): beUI 动效层与两端深色主题,全项目图标改用设计导出的新图标
动效层:把 beUI 的动效语言移植到两端,令牌放在 packages/shared/src/motion.ts(纯常量,后端也引用得到)。弹簧用数值积分生成 CSS linear(),纯 CSS 即可驱动,不必每组件一个 rAF 循环;两端各一份 composables/motion.ts 负责 DOM 部分,在 app.mount() 之前注入 :root。含 reduced-motion 全局降级与 hover 能力探测;灵动岛只移植单调曲线。

深色主题:Web 管理端补齐,机制与桌面客户端一致;语义色令牌只覆盖颜色,不动圆角与动效令牌。默认跟随系统,用户手动点过之后以用户为准。

图标:build/classhelper.{png,ico} 成为全项目唯一图形源,pnpm icons 从它派生各端图标与界面内品牌标。版本号统一到 1.1.2。
2026-10-06 15:32:01 +08:00

240 lines
9.2 KiB
TypeScript
Raw 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 那套动效语言在客户端的落地层。
*
* 令牌的**数值**在 `@classhelper/shared` 的 `motion.ts`(单一来源);
* 这里只放需要 DOM / Vue 的部分。内容与 Web 管理端的
* `packages/web-admin/src/composables/motion.ts` 一致 —— 与 `ScoreBarChart.vue`
* 一样,两端各一份是仓库既有做法。
*
* 客户端相较 Web 端多一层现实约束:教室机器多为**希沃触摸屏**,
* 触摸点按会产生粘住的幽灵 hover,因此 `useHoverCapable()` 在客户端是**必需**的,
* 不是锦上添花(见 AGENTS.md §5 第 30 条)。
*/
import { computed, onUnmounted, ref, watch, type CSSProperties, type Ref } from 'vue';
import {
EASE_OUT,
HOVER_CAPABLE_QUERY,
MOTION_DURATION,
REDUCED_MOTION_QUERY,
cubicBezierCss,
motionCssVars,
} from '@classhelper/shared';
/**
* 把动效令牌写进 `:root` 的自定义属性。
*
* **必须在 `app.mount()` 之前调用**:那样 CSS 里可以放心写 `var(--ch-spring-panel)`,
* 首帧之前就已就位,既没有 FOUC,也不需要到处写兜底值。
*
* ⚠️ 只给**主渲染进程**注入。灵动岛是另一个渲染进程(`src/island/main.ts`),
* 它有自己那套从 WinIsland 移植的弹簧物理,刻意不参与这里的动效体系。
*
* 弹簧用 CSS `linear()` 表达;Electron 44(Chromium 13x)原生支持,
* 仍保留 `cubic-bezier` 回落分支以防将来降版本。
*/
export function applyMotionTokens(): void {
if (typeof document === 'undefined') return;
const linear =
typeof CSS !== 'undefined' &&
typeof CSS.supports === 'function' &&
CSS.supports('transition-timing-function', 'linear(0, 1)');
for (const [name, value] of Object.entries(motionCssVars({ linear }))) {
document.documentElement.style.setProperty(name, value);
}
}
/**
* 监听 `prefers-reduced-motion`。
*
* 三元表达式而非直接返回 `matchMedia(...)` 的结果:这条偏好可能在运行中被改动,
* 必须跟着变;JS 驱动的动画(数字滚动、位置测量)拿不到 CSS 那层自动降级,得自己看这个值。
*/
export function useReducedMotion(): Ref<boolean> {
const matches = ref(false);
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return matches;
const query = window.matchMedia(REDUCED_MOTION_QUERY);
matches.value = query.matches;
const update = (): void => {
matches.value = query.matches;
};
// Electron 的 Chromium 支持 addEventListener;保留旧接口兜底
if (typeof query.addEventListener === 'function') {
query.addEventListener('change', update);
onUnmounted(() => query.removeEventListener('change', update));
} else if (typeof query.addListener === 'function') {
query.addListener(update);
onUnmounted(() => query.removeListener(update));
}
return matches;
}
/**
* 是否有真正的悬停能力(鼠标 / 触控板)。
*
* 触摸设备在点按时会触发**幽灵 hover** 并一直粘着,直到点别处才消失 ——
* 教室里的希沃触摸屏正属于这种,所有 hover-only 效果(抬升、磁吸、光标跟随)
* 都必须先过这一关,否则触摸屏上会出现"点一下,卡片就永远浮着"。
*/
export function useHoverCapable(): Ref<boolean> {
const capable = ref(false);
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return capable;
const query = window.matchMedia(HOVER_CAPABLE_QUERY);
capable.value = query.matches;
const update = (): void => {
capable.value = query.matches;
};
if (typeof query.addEventListener === 'function') {
query.addEventListener('change', update);
onUnmounted(() => query.removeEventListener('change', update));
} else if (typeof query.addListener === 'function') {
query.addListener(update);
onUnmounted(() => query.removeListener(update));
}
return capable;
}
/** 缓动函数:给定进度 t∈[0,1],返回缓动后的进度。即 EASE_OUT 那条贝塞尔的求值。 */
function easeOutProgress(t: number): number {
const [x1, y1, x2, y2] = EASE_OUT;
// 牛顿迭代反解 x(u) = t,再用同一个 u 求 y —— 三次贝塞尔求值的标准做法
let u = t;
for (let i = 0; i < 6; i += 1) {
const x = 3 * (1 - u) ** 2 * u * x1 + 3 * (1 - u) * u ** 2 * x2 + u ** 3;
const dx = 3 * (1 - u) ** 2 * x1 + 6 * (1 - u) * u * (x2 - x1) + 3 * u ** 2 * (1 - x2);
if (Math.abs(dx) < 1e-6) break;
u = Math.min(1, Math.max(0, u - (x - t) / dx));
}
return 3 * (1 - u) ** 2 * u * y1 + 3 * (1 - u) * u ** 2 * y2 + u ** 3;
}
export interface CountUpOptions {
/** 时长(毫秒)。默认 `MOTION_DURATION.slow`。 */
duration?: number;
}
/**
* 数字滚动:目标值变化时从当前显示值滚过去,而不是直接跳。
*
* `reduced-motion` 下**直接落位**(不启动 rAF)。
*
* 刻意**不**给"首次变化"开豁免:数据是异步加载的,挂载那一刻目标值还是 0,
* 真正想看到滚动的恰恰是"0 → 实际值"这第一跳;而如果数据在挂载时就已就位
* (离线缓存命中),`displayed` 初始化即等于目标值、根本不会触发 watch。
*/
export function useCountUp(source: () => number, options: CountUpOptions = {}): Ref<number> {
const reduce = useReducedMotion();
const duration = options.duration ?? MOTION_DURATION.slow;
const displayed = ref(source());
let frame = 0;
function stop(): void {
if (frame) {
cancelAnimationFrame(frame);
frame = 0;
}
}
watch(source, (target) => {
stop();
if (reduce.value || duration <= 0) {
displayed.value = target;
return;
}
const from = displayed.value;
const delta = target - from;
if (delta === 0) return;
const start = performance.now();
const tick = (now: number): void => {
const progress = Math.min(1, (now - start) / duration);
displayed.value = from + delta * easeOutProgress(progress);
if (progress < 1) frame = requestAnimationFrame(tick);
else {
displayed.value = target;
frame = 0;
}
};
frame = requestAnimationFrame(tick);
});
onUnmounted(stop);
return displayed;
}
/**
* 列表错峰入场:返回一个"给第 index 项算内联样式"的函数,
* 配合 CSS 类 `.ch-reveal`(见 `styles/index.css`)使用。
*
* 延迟**封顶**(默认第 8 项之后不再累加):一屏几十行时,最后一行不该等一秒才出现。
*/
export function useStaggerIn(step = 35, maxIndex = 8): (index: number) => CSSProperties {
const reduce = useReducedMotion();
return (index: number): CSSProperties =>
reduce.value ? {} : ({ '--ch-reveal-delay': `${Math.min(index, maxIndex) * step}ms` } as CSSProperties);
}
/** 把 reduced-motion 折成一个布尔,供模板里判断"要不要挂动画类"。 */
export function useMotionEnabled(): Ref<boolean> {
const reduce = useReducedMotion();
return computed(() => !reduce.value);
}
/**
* 整页换肤的揭示动画(beUI 的 `theme-toggle`):新旧两帧各存一张快照,
* 让新帧从一个**从点击位置扩散的圆**里长出来 —— 这是"整个界面换了张皮"这件事
* 唯一能让人**看见**的表达方式,比整页硬切一下好懂得多。
*
* 返回的函数直接挂 `@click`:`@click="reveal($event, () => ui.setTheme(next))"`。
*
* 三条守卫缺一不可:
* ① 不认识 View Transition API 的浏览器直接走同步切换;
* ② 系统开了「减少动态效果」时不放全屏动画;
* ③ **文档不可见时不放**(最小化、被遮挡、无头浏览器)—— 那种情况下浏览器不产生帧,
* 而 View Transition 的更新回调要等到下一个渲染时机才执行,**主题会一直不切**。
* 教室机器上窗口被别的窗口盖住是常态,这条尤其要守。
*/
export function useThemeReveal(): (event: MouseEvent, apply: () => void) => void {
const reduce = useReducedMotion();
return (event, apply) => {
const doc = document as Document & {
startViewTransition?: (callback: () => void) => { ready: Promise<void> };
};
if (
typeof doc.startViewTransition !== 'function' ||
reduce.value ||
document.visibilityState !== 'visible'
) {
apply();
return;
}
const { clientX: x, clientY: y } = event;
void doc
.startViewTransition(apply)
.ready.then(() => {
// 半径取到最远的那个角,圆才能把整屏吃完
const radius = Math.hypot(
Math.max(x, window.innerWidth - x),
Math.max(y, window.innerHeight - y),
);
document.documentElement.animate(
{ clipPath: [`circle(0px at ${x}px ${y}px)`, `circle(${radius}px at ${x}px ${y}px)`] },
{
// 比常规过渡长一点 —— 它是一次**整屏**的变化,太快会读成"闪了一下"而不是"换了一层皮"
duration: MOTION_DURATION.slow * 1.5,
// 缓动取自共享令牌,别在这里手写曲线字面量(那是漂移的起点)
easing: cubicBezierCss(EASE_OUT),
pseudoElement: '::view-transition-new(root)',
},
);
})
.catch(() => undefined);
};
}