Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 270 lines 7.0 kB view raw
1import {useEffect} from 'react' 2import {type FlatList} from 'react-native' 3 4import {ITEM_GAP} from '#/components/images/Gallery/const' 5import {tween} from '#/components/images/Gallery/tween' 6import {getOffsetForIndex} from '#/components/images/Gallery/utils' 7 8const DRAG_THRESHOLD = 3 9const FLICK_DECAY = 0.85 10const FLICK_MIN_VELOCITY = 0.1 11const ADVANCE_THRESHOLD = 0.15 12const FRAME_MS = 1000 / 60 13const SETTLE_DURATION = 700 14const OVERSCROLL_RESISTANCE = 0.4 15const BOUNCE_DURATION = 700 16 17function whichByDistance( 18 itemWidths: Map<number, number>, 19 currentIndex: number, 20 distance: number, 21 direction: -1 | 1, 22 imageCount: number, 23): number { 24 let remaining = distance 25 let i = currentIndex 26 27 while (remaining > 0 && i >= 0 && i < imageCount) { 28 const w = (itemWidths.get(i) ?? 0) + ITEM_GAP 29 if (remaining > w) { 30 remaining -= w 31 i -= direction 32 } else if (remaining > w * ADVANCE_THRESHOLD) { 33 i -= direction 34 break 35 } else { 36 break 37 } 38 } 39 40 return Math.max(0, Math.min(i, imageCount - 1)) 41} 42 43export function usePointerHandlers({ 44 flatListRef, 45 itemWidthsRef, 46 currentIndexRef, 47 scrollTo, 48 onSettle, 49 imageCount, 50}: { 51 flatListRef: React.RefObject<FlatList | null> 52 itemWidthsRef: React.RefObject<Map<number, number>> 53 currentIndexRef: React.RefObject<number> 54 scrollTo: (offset: number) => void 55 onSettle: (index: number) => void 56 imageCount: number 57}) { 58 useEffect(() => { 59 if (imageCount <= 1) return 60 61 const el = 62 flatListRef.current?.getScrollableNode() as unknown as HTMLElement | null 63 if (!el) return 64 65 let isDragging = false 66 let isMouseDown = false 67 let startX = 0 68 let dragScrollLeft = 0 69 let delta = 0 70 let prevDelta = 0 71 let velo = 0 72 let t = 0 73 let stopTween: (() => void) | null = null 74 let localIndex = currentIndexRef.current 75 let overscrollX = 0 76 77 el.style.cursor = 'grab' 78 79 const clearOverscroll = () => { 80 overscrollX = 0 81 el.style.transform = '' 82 } 83 84 const onMouseDown = (e: MouseEvent) => { 85 e.preventDefault() // prevent native image drag 86 87 // Cancel any in-progress tween 88 if (stopTween) { 89 stopTween() 90 stopTween = null 91 } 92 clearOverscroll() 93 94 isMouseDown = true 95 isDragging = false 96 localIndex = currentIndexRef.current 97 startX = e.pageX 98 dragScrollLeft = el.scrollLeft 99 delta = 0 100 prevDelta = 0 101 velo = 0 102 t = e.timeStamp 103 } 104 105 const onMouseMove = (e: MouseEvent) => { 106 if (!isMouseDown) return 107 108 const x = e.pageX - startX 109 110 // Require minimum movement before starting drag 111 if (!isDragging && Math.abs(x) < DRAG_THRESHOLD) return 112 113 if (!isDragging) { 114 isDragging = true 115 el.style.cursor = 'grabbing' 116 el.style.userSelect = 'none' 117 118 // Blur focused element within the gallery 119 if (el.contains(document.activeElement)) { 120 ;(document.activeElement as HTMLElement)?.blur?.() 121 } 122 } 123 124 e.preventDefault() 125 126 // Track velocity 127 const elapsed = e.timeStamp - t || 1 128 prevDelta = delta 129 delta = x 130 velo = (delta - prevDelta) / (elapsed * FRAME_MS) 131 t = e.timeStamp 132 133 const desiredScroll = dragScrollLeft - delta 134 const maxScroll = el.scrollWidth - el.clientWidth 135 136 if (desiredScroll < 0) { 137 // Overscroll at start — rubber band 138 scrollTo(0) 139 overscrollX = desiredScroll * OVERSCROLL_RESISTANCE 140 el.style.transform = `translateX(${-overscrollX}px)` 141 } else if (desiredScroll > maxScroll) { 142 // Overscroll at end — rubber band 143 scrollTo(maxScroll) 144 overscrollX = (desiredScroll - maxScroll) * OVERSCROLL_RESISTANCE 145 el.style.transform = `translateX(${-overscrollX}px)` 146 } else { 147 // Normal scroll range 148 scrollTo(desiredScroll) 149 if (overscrollX !== 0) clearOverscroll() 150 } 151 152 // Update local index from scroll position (only in normal range) 153 if (overscrollX === 0) { 154 const offsetX = desiredScroll 155 let accumulated = 0 156 for (let i = 0; i < imageCount; i++) { 157 const w = (itemWidthsRef.current.get(i) ?? 0) + ITEM_GAP 158 if (offsetX < accumulated + w / 2) { 159 localIndex = i 160 break 161 } 162 accumulated += w 163 if (i === imageCount - 1) localIndex = i 164 } 165 } 166 } 167 168 const onMouseUp = () => { 169 if (!isMouseDown) return 170 171 const wasDragging = isDragging 172 isMouseDown = false 173 isDragging = false 174 175 el.style.cursor = 'grab' 176 el.style.userSelect = '' 177 178 if (wasDragging) { 179 // Suppress the click that follows mouseup after a drag 180 el.addEventListener('click', e => e.stopPropagation(), { 181 once: true, 182 capture: true, 183 }) 184 185 if (overscrollX !== 0) { 186 // Bounce back from overscroll 187 const targetIndex = overscrollX > 0 ? imageCount - 1 : 0 188 const fromOverscroll = overscrollX 189 190 stopTween = tween( 191 fromOverscroll, 192 0, 193 BOUNCE_DURATION, 194 )( 195 v => { 196 el.style.transform = `translateX(${-v}px)` 197 }, 198 () => { 199 stopTween = null 200 clearOverscroll() 201 onSettle(targetIndex) 202 }, 203 ) 204 } else { 205 // Normal flick settle 206 let v = Math.abs(velo) 207 let restingDistance = 0 208 while (v > FLICK_MIN_VELOCITY) { 209 v *= FLICK_DECAY 210 restingDistance += v 211 } 212 213 const direction: -1 | 1 = delta < 0 ? -1 : 1 214 const totalDistance = Math.abs(delta) + restingDistance 215 216 const targetIndex = whichByDistance( 217 itemWidthsRef.current, 218 localIndex, 219 totalDistance, 220 direction, 221 imageCount, 222 ) 223 224 const from = el.scrollLeft 225 const to = getOffsetForIndex(itemWidthsRef.current, targetIndex) 226 227 if (from === to) { 228 onSettle(targetIndex) 229 return 230 } 231 232 stopTween = tween( 233 from, 234 to, 235 SETTLE_DURATION, 236 )( 237 v => { 238 scrollTo(v) 239 }, 240 () => { 241 stopTween = null 242 onSettle(targetIndex) 243 }, 244 ) 245 } 246 } 247 } 248 249 el.addEventListener('mousedown', onMouseDown) 250 window.addEventListener('mousemove', onMouseMove) 251 window.addEventListener('mouseup', onMouseUp) 252 253 return () => { 254 el.removeEventListener('mousedown', onMouseDown) 255 window.removeEventListener('mousemove', onMouseMove) 256 window.removeEventListener('mouseup', onMouseUp) 257 if (stopTween) stopTween() 258 clearOverscroll() 259 el.style.cursor = '' 260 el.style.userSelect = '' 261 } 262 }, [ 263 flatListRef, 264 itemWidthsRef, 265 currentIndexRef, 266 scrollTo, 267 onSettle, 268 imageCount, 269 ]) 270}