forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 💫
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}