动效层:把 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。
240 lines
9.2 KiB
TypeScript
240 lines
9.2 KiB
TypeScript
/**
|
||
* 动效组合式函数 —— 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);
|
||
};
|
||
}
|