forked from natuka/web.puabadge.com
104 lines
2.9 KiB
TypeScript
104 lines
2.9 KiB
TypeScript
export type FlyRect = {
|
|
left: number
|
|
top: number
|
|
width: number
|
|
height: number
|
|
}
|
|
|
|
function prefersReducedMotion() {
|
|
return typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
}
|
|
|
|
/** 二次贝塞尔:起点 → 控制点 → 终点 */
|
|
function quad(p0: number, p1: number, p2: number, t: number) {
|
|
const u = 1 - t
|
|
return u * u * p0 + 2 * u * t * p1 + t * t * p2
|
|
}
|
|
|
|
function easeInOut(t: number) {
|
|
return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2
|
|
}
|
|
|
|
/** 将图片从源矩形沿抛物线飞到目标矩形,动画结束后移除 */
|
|
export function flyImageToRect(url: string, from: FlyRect, to: FlyRect, duration = 860): Promise<void> {
|
|
if (typeof document === 'undefined' || !url || from.width <= 0 || from.height <= 0) {
|
|
return Promise.resolve()
|
|
}
|
|
if (prefersReducedMotion()) return Promise.resolve()
|
|
|
|
const ghost = document.createElement('img')
|
|
ghost.src = url
|
|
ghost.alt = ''
|
|
ghost.className = 'jd-img-fly'
|
|
ghost.setAttribute('aria-hidden', 'true')
|
|
Object.assign(ghost.style, {
|
|
position: 'fixed',
|
|
left: `${from.left}px`,
|
|
top: `${from.top}px`,
|
|
width: `${from.width}px`,
|
|
height: `${from.height}px`,
|
|
margin: '0',
|
|
padding: '0',
|
|
border: '0',
|
|
borderRadius: '12px',
|
|
objectFit: 'contain',
|
|
objectPosition: 'center',
|
|
background: '#e8eaed',
|
|
zIndex: '10000',
|
|
pointerEvents: 'none',
|
|
boxShadow: '0 12px 28px rgba(15, 23, 42, 0.22)',
|
|
transformOrigin: 'top left',
|
|
willChange: 'transform, opacity, border-radius',
|
|
} as CSSStyleDeclaration)
|
|
|
|
document.body.appendChild(ghost)
|
|
|
|
const sxEnd = to.width / Math.max(1, from.width)
|
|
const syEnd = to.height / Math.max(1, from.height)
|
|
const dx = to.left - from.left
|
|
const dy = to.top - from.top
|
|
|
|
const lift = Math.min(160, Math.max(72, Math.hypot(dx, dy) * 0.28))
|
|
const cx = dx * 0.5
|
|
const cy = dy * 0.5 - lift
|
|
|
|
const steps = 18
|
|
const keyframes: Keyframe[] = []
|
|
for (let i = 0; i <= steps; i += 1) {
|
|
const raw = i / steps
|
|
const t = easeInOut(raw)
|
|
const x = quad(0, cx, dx, t)
|
|
const y = quad(0, cy, dy, t)
|
|
const scaleX = 1 + (sxEnd - 1) * t
|
|
const scaleY = 1 + (syEnd - 1) * t
|
|
const radius = 12 + (10 - 12) * t
|
|
keyframes.push({
|
|
offset: raw,
|
|
transform: `translate(${x}px, ${y}px) scale(${scaleX}, ${scaleY})`,
|
|
opacity: String(1 - 0.08 * t),
|
|
borderRadius: `${radius}px`,
|
|
})
|
|
}
|
|
|
|
return new Promise((resolve) => {
|
|
const finish = () => {
|
|
ghost.remove()
|
|
resolve()
|
|
}
|
|
const anim = ghost.animate(keyframes, {
|
|
duration,
|
|
easing: 'linear',
|
|
fill: 'forwards',
|
|
})
|
|
anim.onfinish = finish
|
|
anim.oncancel = finish
|
|
})
|
|
}
|
|
|
|
export function rectFromElement(el: Element | null | undefined): FlyRect | null {
|
|
if (!el) return null
|
|
const box = el.getBoundingClientRect()
|
|
if (box.width <= 0 || box.height <= 0) return null
|
|
return { left: box.left, top: box.top, width: box.width, height: box.height }
|
|
}
|