Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 | import { useEffect, useRef, useState } from 'react'
const PULL_THRESHOLD = 70
const ACTIVATION_DISTANCE = 10
const MAX_PULL = PULL_THRESHOLD * 1.6
function isAtTop(): boolean {
return window.scrollY <= 0 && document.documentElement.scrollTop <= 0
}
// If the user starts the gesture inside an internal scroller that is not at
// scrollTop 0, we must not hijack it for refresh — scrolling within the
// scroller stays a normal scroll.
function hasScrolledScrollableAncestor(target: Element | null): boolean {
let node: Element | null = target
while (node && node !== document.body && node !== document.documentElement) {
const style = window.getComputedStyle(node)
const overflowY = style.overflowY
if ((overflowY === 'auto' || overflowY === 'scroll') && node.scrollHeight > node.clientHeight) {
if (node.scrollTop > 0) return true
}
node = node.parentElement
}
return false
}
export interface PullToRefreshState {
pullDistance: number
isPulling: boolean
isReleasing: boolean
threshold: number
}
export function usePullToRefresh(): PullToRefreshState {
const [pullDistance, setPullDistance] = useState(0)
const [isPulling, setIsPulling] = useState(false)
const [isReleasing, setIsReleasing] = useState(false)
const startYRef = useRef<number | null>(null)
const distanceRef = useRef(0)
const pullingRef = useRef(false)
useEffect(() => {
function reset() {
startYRef.current = null
distanceRef.current = 0
pullingRef.current = false
setPullDistance(0)
setIsPulling(false)
}
function onTouchStart(e: TouchEvent) {
if (e.touches.length !== 1) return
if (!isAtTop()) return
const target = e.target as Element | null
if (!target) return
if (target.closest('input, textarea, select, [contenteditable="true"], [data-no-ptr]')) return
if (hasScrolledScrollableAncestor(target)) return
startYRef.current = e.touches[0].clientY
}
function onTouchMove(e: TouchEvent) {
if (startYRef.current === null) return
if (e.touches.length !== 1) {
reset()
return
}
const dy = e.touches[0].clientY - startYRef.current
if (dy <= 0) {
reset()
return
}
if (dy < ACTIVATION_DISTANCE) return
// Block native rubber-band so the indicator owns the gesture.
if (e.cancelable) e.preventDefault()
const visible = Math.min(dy, MAX_PULL)
distanceRef.current = visible
pullingRef.current = true
setPullDistance(visible)
setIsPulling(true)
}
function onTouchEnd() {
if (pullingRef.current && distanceRef.current >= PULL_THRESHOLD) {
// Show the "releasing" state briefly so the user gets visual confirmation
// before the page reloads. setTimeout lets React paint one frame.
setIsReleasing(true)
setTimeout(() => window.location.reload(), 80)
return
}
reset()
}
document.addEventListener('touchstart', onTouchStart, { passive: true })
document.addEventListener('touchmove', onTouchMove, { passive: false })
document.addEventListener('touchend', onTouchEnd, { passive: true })
document.addEventListener('touchcancel', reset, { passive: true })
return () => {
document.removeEventListener('touchstart', onTouchStart)
document.removeEventListener('touchmove', onTouchMove as EventListener)
document.removeEventListener('touchend', onTouchEnd)
document.removeEventListener('touchcancel', reset as EventListener)
}
}, [])
return { pullDistance, isPulling, isReleasing, threshold: PULL_THRESHOLD }
}
|