import { useEffect, type RefObject } from 'react' import { getAppearance, subscribeAppearance } from './appearance' /** 指针在窗口里的位置带来的偏移上限(像素):鼠标停在窗口边缘时背景偏这么多。 */ const POSITION_SHIFT = 30 /** 鼠标移动时额外的晃动上限(像素):移得越快晃得越多,停下来就慢慢回到位置对应的偏移。两项加起来不能超过 display.css 里背景层向外多出来的边距(130px)。 */ const SWAY_MAX = 90 /** 背景最多偏移多少像素(要比 display.css 里背景层向外多出来的边距 130px 小,否则会露边;设置里把幅度调大时靠它兜底)。 */ const MAX_OFFSET = 125 /** 每移动 1 像素,晃动增加多少像素。 */ const SWAY_GAIN = 0.8 /** 晃动每一帧衰减到多少:越接近 1,晃得越久。 */ const SWAY_DECAY = 0.94 /** 每一帧追上目标的比例:越小越慢、拖拽感越强。 */ const EASE = 0.05 /** * 背景跟着鼠标(或手指)移动,带一点拖拽的惯性: * - 位置:指针在窗口里的位置对应一个小的偏移(最多 POSITION_SHIFT),背景每帧只追上一小段; * - 晃动:鼠标移动的时候,移动的距离再叠加一份额外的偏移(移得越快越大,最多 SWAY_MAX),停下来之后逐渐衰减,所以移动时背景「晃」得更明显。 * 指针离开窗口,背景慢慢回到中间。直接改背景层自己的 transform(不经过 CSS 变量:变量会让整页的样式重新计算,卡片又多); * 追上并且晃动衰减完之后就停止动画帧,不空转。用户开了「减少动态效果」就什么都不做。 */ export function useBackgroundParallax(layer: RefObject) { useEffect(() => { if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return const element = layer.current if (!element) return let baseX = 0 let baseY = 0 let swayX = 0 let swayY = 0 let x = 0 let y = 0 let lastX: number | null = null let lastY: number | null = null let frame = 0 const step = () => { swayX *= SWAY_DECAY swayY *= SWAY_DECAY const targetX = baseX + swayX const targetY = baseY + swayY x += (targetX - x) * EASE y += (targetY - y) * EASE element.style.transform = `translate3d(${Math.max(-MAX_OFFSET, Math.min(MAX_OFFSET, x)).toFixed(2)}px, ${Math.max(-MAX_OFFSET, Math.min(MAX_OFFSET, y)).toFixed(2)}px, 0)` const moving = Math.abs(targetX - x) > 0.05 || Math.abs(targetY - y) > 0.05 || Math.abs(swayX) > 0.05 || Math.abs(swayY) > 0.05 frame = moving ? requestAnimationFrame(step) : 0 } const schedule = () => { if (!frame) frame = requestAnimationFrame(step) } const clamp = (value: number) => Math.max(-SWAY_MAX, Math.min(SWAY_MAX, value)) const move = (event: PointerEvent) => { // 位置:指针在窗口里的位置换成 -1 到 1(背景朝同一个方向偏移,想要反方向就把符号取反) const amount = swayAmount() baseX = ((event.clientX / window.innerWidth) * 2 - 1) * POSITION_SHIFT * amount baseY = ((event.clientY / window.innerHeight) * 2 - 1) * POSITION_SHIFT * amount // 晃动:这一次移动了多远,就再推背景一把 if (lastX !== null && lastY !== null) { swayX = clamp(swayX + (event.clientX - lastX) * SWAY_GAIN * amount) swayY = clamp(swayY + (event.clientY - lastY) * SWAY_GAIN * amount) } lastX = event.clientX lastY = event.clientY schedule() } // 晃动的幅度:总开关关了就是 0 const swayAmount = () => { const { sway, parallax } = getAppearance() return sway ? parallax : 0 } const leave = () => { baseX = 0 baseY = 0 lastX = null lastY = null schedule() } window.addEventListener('pointermove', move, { passive: true }) document.documentElement.addEventListener('mouseleave', leave) // 设置里把晃动关了:背景慢慢回到中间,不用等下一次鼠标移动 const unsubscribe = subscribeAppearance(() => { if (swayAmount() === 0) { swayX = 0 swayY = 0 leave() } }) return () => { unsubscribe() window.removeEventListener('pointermove', move) document.documentElement.removeEventListener('mouseleave', leave) cancelAnimationFrame(frame) element.style.transform = '' } }, [layer]) }