100 lines
2.9 KiB
TypeScript
100 lines
2.9 KiB
TypeScript
import type { MaybeElementRef } from '@vueuse/core'
|
|
import { isClient, unrefElement } from '@vueuse/core'
|
|
|
|
const FLIGHT_DURATION = 560
|
|
const FLIGHT_STEPS = 16
|
|
/** 抛物线顶点相对起点额外抬高的距离 */
|
|
const FLIGHT_LIFT = 72
|
|
|
|
function prefersReducedMotion() {
|
|
return window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
}
|
|
|
|
function center(rect: DOMRect) {
|
|
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }
|
|
}
|
|
|
|
/**
|
|
* 篮子按钮“接住”食材的回弹
|
|
*/
|
|
function bump(el: Element) {
|
|
el.animate([
|
|
{ transform: 'scale(1)' },
|
|
{ transform: 'scale(1.18)', offset: 0.35 },
|
|
{ transform: 'scale(0.94)', offset: 0.7 },
|
|
{ transform: 'scale(1)' },
|
|
], { duration: 420, easing: 'ease-out' })
|
|
}
|
|
|
|
/**
|
|
* 选中食材时,emoji 沿抛物线飞进篮子按钮
|
|
*/
|
|
export function useEmojiAnimation(basket: MaybeElementRef) {
|
|
const playAnimation = (emoji: string, origin?: Element | null) => {
|
|
if (!isClient)
|
|
return
|
|
|
|
const basketEl = unrefElement(basket)
|
|
// 落点优先用标记了 data-flight-target 的元素,其次是容器里真正的按钮
|
|
const target = basketEl?.querySelector('[data-flight-target], ion-fab-button, button') ?? basketEl
|
|
if (!target?.isConnected)
|
|
return
|
|
|
|
const to = target.getBoundingClientRect()
|
|
if (!to.width || !to.height)
|
|
return
|
|
|
|
if (!origin || prefersReducedMotion()) {
|
|
if (!prefersReducedMotion())
|
|
bump(target)
|
|
return
|
|
}
|
|
|
|
const start = center(origin.getBoundingClientRect())
|
|
const end = center(to)
|
|
const dx = end.x - start.x
|
|
const dy = end.y - start.y
|
|
// 二次贝塞尔控制点:水平居中,竖直方向高于起点与终点
|
|
const cx = dx / 2
|
|
const cy = Math.min(0, dy) - FLIGHT_LIFT
|
|
|
|
const keyframes: Keyframe[] = []
|
|
for (let i = 0; i <= FLIGHT_STEPS; i++) {
|
|
const t = i / FLIGHT_STEPS
|
|
const x = 2 * (1 - t) * t * cx + t * t * dx
|
|
const y = 2 * (1 - t) * t * cy + t * t * dy
|
|
const scale = t < 0.2 ? 1 + t * 1.5 : 1.3 - (t - 0.2) * 1.1
|
|
keyframes.push({
|
|
transform: `translate(-50%, -50%) translate(${x}px, ${y}px) rotate(${t * (dx >= 0 ? 30 : -30)}deg) scale(${scale})`,
|
|
opacity: t > 0.85 ? 1 - (t - 0.85) / 0.15 * 0.7 : 1,
|
|
})
|
|
}
|
|
|
|
const emojiEl = document.createElement('span')
|
|
emojiEl.textContent = emoji
|
|
emojiEl.setAttribute('aria-hidden', 'true')
|
|
Object.assign(emojiEl.style, {
|
|
position: 'fixed',
|
|
left: `${start.x}px`,
|
|
top: `${start.y}px`,
|
|
zIndex: '1000',
|
|
fontSize: '20px',
|
|
lineHeight: '1',
|
|
pointerEvents: 'none',
|
|
willChange: 'transform, opacity',
|
|
})
|
|
document.body.appendChild(emojiEl)
|
|
|
|
const flight = emojiEl.animate(keyframes, {
|
|
duration: FLIGHT_DURATION,
|
|
easing: 'cubic-bezier(0.45, 0, 0.75, 1)',
|
|
fill: 'forwards',
|
|
})
|
|
flight.finished.then(() => bump(target), () => {}).finally(() => emojiEl.remove())
|
|
}
|
|
|
|
return {
|
|
playAnimation,
|
|
}
|
|
}
|