134 lines
4.3 KiB
TypeScript
134 lines
4.3 KiB
TypeScript
"use client"
|
|
|
|
import { useCallback, useEffect, useRef, useState } from "react"
|
|
|
|
export const PULL_HOLD_HEIGHT = 48
|
|
export const PULL_REFRESH_THRESHOLD = 56
|
|
export const PULL_REFRESH_MAX = 112
|
|
export const PULL_SNAP_BACK_TRANSITION =
|
|
"transform 0.24s cubic-bezier(0.32, 0.72, 0, 1)"
|
|
export const REFRESH_SPIN_CLASS = "animate-[spin_0.55s_linear_infinite]"
|
|
export const PULL_ICON_FADE_MS = 120
|
|
export const PULL_SPINNER_REVEAL_OFFSET = 26
|
|
|
|
export function computePullOffset(delta: number): number {
|
|
if (delta <= 0) return 0
|
|
const damped = delta * 0.48
|
|
const capped = Math.min(PULL_REFRESH_MAX, damped)
|
|
const ratio = capped / PULL_REFRESH_MAX
|
|
return capped * (1 - ratio * 0.12)
|
|
}
|
|
|
|
export function computeSpinnerRevealProgress(y: number): number {
|
|
if (y <= PULL_SPINNER_REVEAL_OFFSET) return 0
|
|
const range = Math.max(1, PULL_REFRESH_THRESHOLD - PULL_SPINNER_REVEAL_OFFSET)
|
|
return Math.min(1, ((y - PULL_SPINNER_REVEAL_OFFSET) / range) * 1.35)
|
|
}
|
|
|
|
export function useMailListPullRefresh(opts: {
|
|
enabled: boolean
|
|
isViewMode: boolean
|
|
onRefresh: () => void | Promise<void>
|
|
}) {
|
|
const { enabled, isViewMode, onRefresh } = opts
|
|
const [isRefreshing, setIsRefreshing] = useState(false)
|
|
const pullYRef = useRef(0)
|
|
const pullActiveRef = useRef(false)
|
|
const pullContentRef = useRef<HTMLDivElement>(null)
|
|
const pullIconRef = useRef<SVGSVGElement | null>(null)
|
|
const listViewportRef = useRef<HTMLDivElement>(null)
|
|
|
|
const applyPullVisual = useCallback((y: number, animate: boolean) => {
|
|
const el = pullContentRef.current
|
|
const icon = pullIconRef.current
|
|
if (!el) return
|
|
const transition = animate ? PULL_SNAP_BACK_TRANSITION : "none"
|
|
el.style.transition = transition
|
|
el.style.transform = y > 0 ? `translateY(${y}px)` : ""
|
|
if (icon) {
|
|
const progress = computeSpinnerRevealProgress(y)
|
|
icon.style.opacity = String(progress)
|
|
icon.style.transform = `rotate(${progress * 360}deg)`
|
|
icon.style.transition = animate
|
|
? `opacity ${PULL_ICON_FADE_MS}ms ease-out, transform ${PULL_ICON_FADE_MS}ms ease-out`
|
|
: "none"
|
|
}
|
|
}, [])
|
|
|
|
const resetPullVisual = useCallback(() => {
|
|
pullYRef.current = 0
|
|
applyPullVisual(0, true)
|
|
}, [applyPullVisual])
|
|
|
|
const releasePull = useCallback(async () => {
|
|
const offset = pullYRef.current
|
|
pullActiveRef.current = false
|
|
if (offset >= PULL_REFRESH_THRESHOLD) {
|
|
pullYRef.current = PULL_HOLD_HEIGHT
|
|
applyPullVisual(PULL_HOLD_HEIGHT, false)
|
|
setIsRefreshing(true)
|
|
try {
|
|
await onRefresh()
|
|
} finally {
|
|
setIsRefreshing(false)
|
|
pullYRef.current = 0
|
|
applyPullVisual(0, true)
|
|
}
|
|
} else {
|
|
resetPullVisual()
|
|
}
|
|
}, [applyPullVisual, onRefresh, resetPullVisual])
|
|
|
|
useEffect(() => {
|
|
if (isViewMode || !enabled || isRefreshing) return
|
|
const root = listViewportRef.current
|
|
if (!root) return
|
|
|
|
let startY = 0
|
|
const onTouchStart = (e: TouchEvent) => {
|
|
if (root.scrollTop > 0 || isRefreshing) return
|
|
startY = e.touches[0]?.clientY ?? 0
|
|
pullActiveRef.current = true
|
|
}
|
|
const onTouchMove = (e: TouchEvent) => {
|
|
if (!pullActiveRef.current || isRefreshing) return
|
|
const y = e.touches[0]?.clientY ?? 0
|
|
const delta = y - startY
|
|
if (delta <= 0) {
|
|
pullYRef.current = 0
|
|
applyPullVisual(0, false)
|
|
return
|
|
}
|
|
if (root.scrollTop > 0) return
|
|
e.preventDefault()
|
|
const offset = computePullOffset(delta)
|
|
pullYRef.current = offset
|
|
applyPullVisual(offset, false)
|
|
}
|
|
const onTouchEnd = () => {
|
|
if (!pullActiveRef.current) return
|
|
void releasePull()
|
|
}
|
|
|
|
root.addEventListener("touchstart", onTouchStart, { passive: true })
|
|
root.addEventListener("touchmove", onTouchMove, { passive: false })
|
|
root.addEventListener("touchend", onTouchEnd)
|
|
root.addEventListener("touchcancel", onTouchEnd)
|
|
return () => {
|
|
root.removeEventListener("touchstart", onTouchStart)
|
|
root.removeEventListener("touchmove", onTouchMove)
|
|
root.removeEventListener("touchend", onTouchEnd)
|
|
root.removeEventListener("touchcancel", onTouchEnd)
|
|
}
|
|
}, [enabled, isRefreshing, isViewMode, applyPullVisual, releasePull])
|
|
|
|
return {
|
|
isRefreshing,
|
|
setIsRefreshing,
|
|
listViewportRef,
|
|
pullContentRef,
|
|
pullIconRef,
|
|
resetPullVisual,
|
|
}
|
|
}
|