d3c9ed027c
Co-authored-by: Eric Bailey <git@esb.lol>
324 lines
8.9 KiB
TypeScript
324 lines
8.9 KiB
TypeScript
import {useEffect} from 'react'
|
|
import {type FlatList} from 'react-native'
|
|
|
|
import {ITEM_GAP} from '#/components/images/Gallery/const'
|
|
import {tween} from '#/components/images/Gallery/tween'
|
|
import {getOffsetForIndex} from '#/components/images/Gallery/utils'
|
|
import {IS_WEB_SAFARI} from '#/env'
|
|
|
|
const DRAG_THRESHOLD = 3
|
|
const FLICK_DECAY = 0.85
|
|
const FLICK_MIN_VELOCITY = 0.1
|
|
const ADVANCE_THRESHOLD = 0.15
|
|
const FRAME_MS = 1000 / 60
|
|
const SETTLE_DURATION = 700
|
|
const OVERSCROLL_RESISTANCE = 0.4
|
|
const BOUNCE_DURATION = 700
|
|
|
|
function whichByDistance(
|
|
itemWidths: Map<number, number>,
|
|
currentIndex: number,
|
|
distance: number,
|
|
direction: -1 | 1,
|
|
imageCount: number,
|
|
): number {
|
|
let remaining = distance
|
|
let i = currentIndex
|
|
|
|
while (remaining > 0 && i >= 0 && i < imageCount) {
|
|
const w = (itemWidths.get(i) ?? 0) + ITEM_GAP
|
|
if (remaining > w) {
|
|
remaining -= w
|
|
i -= direction
|
|
} else if (remaining > w * ADVANCE_THRESHOLD) {
|
|
i -= direction
|
|
break
|
|
} else {
|
|
break
|
|
}
|
|
}
|
|
|
|
return Math.max(0, Math.min(i, imageCount - 1))
|
|
}
|
|
|
|
export function usePointerHandlers({
|
|
flatListRef,
|
|
itemWidthsRef,
|
|
currentIndexRef,
|
|
scrollTo,
|
|
onSettle,
|
|
imageCount,
|
|
}: {
|
|
flatListRef: React.RefObject<FlatList | null>
|
|
itemWidthsRef: React.RefObject<Map<number, number>>
|
|
currentIndexRef: React.RefObject<number>
|
|
scrollTo: (offset: number) => void
|
|
onSettle: (index: number) => void
|
|
imageCount: number
|
|
}) {
|
|
useEffect(() => {
|
|
if (imageCount <= 1) return
|
|
|
|
const el =
|
|
flatListRef.current?.getScrollableNode() as unknown as HTMLElement | null
|
|
if (!el) return
|
|
|
|
let isDragging = false
|
|
let isMouseDown = false
|
|
let startX = 0
|
|
let dragScrollLeft = 0
|
|
let delta = 0
|
|
let prevDelta = 0
|
|
let velo = 0
|
|
let t = 0
|
|
let stopTween: (() => void) | null = null
|
|
let localIndex = currentIndexRef.current
|
|
let overscrollX = 0
|
|
|
|
el.style.cursor = 'grab'
|
|
|
|
const clearOverscroll = () => {
|
|
overscrollX = 0
|
|
el.style.transform = ''
|
|
}
|
|
|
|
const onMouseDown = (e: MouseEvent) => {
|
|
e.preventDefault() // prevent native image drag
|
|
|
|
// Cancel any in-progress tween
|
|
if (stopTween) {
|
|
stopTween()
|
|
stopTween = null
|
|
}
|
|
clearOverscroll()
|
|
|
|
isMouseDown = true
|
|
isDragging = false
|
|
localIndex = currentIndexRef.current
|
|
startX = e.pageX
|
|
dragScrollLeft = el.scrollLeft
|
|
delta = 0
|
|
prevDelta = 0
|
|
velo = 0
|
|
t = e.timeStamp
|
|
}
|
|
|
|
const onMouseMove = (e: MouseEvent) => {
|
|
if (!isMouseDown) return
|
|
|
|
const x = e.pageX - startX
|
|
|
|
// Require minimum movement before starting drag
|
|
if (!isDragging && Math.abs(x) < DRAG_THRESHOLD) return
|
|
|
|
if (!isDragging) {
|
|
isDragging = true
|
|
el.style.cursor = 'grabbing'
|
|
el.style.userSelect = 'none'
|
|
|
|
// Blur focused element within the gallery
|
|
if (el.contains(document.activeElement)) {
|
|
;(document.activeElement as HTMLElement)?.blur?.()
|
|
}
|
|
}
|
|
|
|
e.preventDefault()
|
|
|
|
// Track velocity
|
|
const elapsed = e.timeStamp - t || 1
|
|
prevDelta = delta
|
|
delta = x
|
|
velo = (delta - prevDelta) / (elapsed * FRAME_MS)
|
|
t = e.timeStamp
|
|
|
|
const desiredScroll = dragScrollLeft - delta
|
|
const maxScroll = el.scrollWidth - el.clientWidth
|
|
|
|
if (desiredScroll < 0) {
|
|
// Overscroll at start — rubber band
|
|
scrollTo(0)
|
|
overscrollX = desiredScroll * OVERSCROLL_RESISTANCE
|
|
el.style.transform = `translateX(${-overscrollX}px)`
|
|
} else if (desiredScroll > maxScroll) {
|
|
// Overscroll at end — rubber band
|
|
scrollTo(maxScroll)
|
|
overscrollX = (desiredScroll - maxScroll) * OVERSCROLL_RESISTANCE
|
|
el.style.transform = `translateX(${-overscrollX}px)`
|
|
} else {
|
|
// Normal scroll range
|
|
scrollTo(desiredScroll)
|
|
if (overscrollX !== 0) clearOverscroll()
|
|
}
|
|
|
|
// Update local index from scroll position (only in normal range)
|
|
if (overscrollX === 0) {
|
|
const offsetX = desiredScroll
|
|
let accumulated = 0
|
|
for (let i = 0; i < imageCount; i++) {
|
|
const w = (itemWidthsRef.current.get(i) ?? 0) + ITEM_GAP
|
|
if (offsetX < accumulated + w / 2) {
|
|
localIndex = i
|
|
break
|
|
}
|
|
accumulated += w
|
|
if (i === imageCount - 1) localIndex = i
|
|
}
|
|
}
|
|
}
|
|
|
|
const onMouseUp = () => {
|
|
if (!isMouseDown) return
|
|
|
|
const wasDragging = isDragging
|
|
isMouseDown = false
|
|
isDragging = false
|
|
|
|
el.style.cursor = 'grab'
|
|
el.style.userSelect = ''
|
|
|
|
if (wasDragging) {
|
|
// Suppress the click that follows mouseup after a drag
|
|
el.addEventListener('click', e => e.stopPropagation(), {
|
|
once: true,
|
|
capture: true,
|
|
})
|
|
|
|
if (overscrollX !== 0) {
|
|
// Bounce back from overscroll
|
|
const targetIndex = overscrollX > 0 ? imageCount - 1 : 0
|
|
const fromOverscroll = overscrollX
|
|
|
|
stopTween = tween(
|
|
fromOverscroll,
|
|
0,
|
|
BOUNCE_DURATION,
|
|
)(
|
|
v => {
|
|
el.style.transform = `translateX(${-v}px)`
|
|
},
|
|
() => {
|
|
stopTween = null
|
|
clearOverscroll()
|
|
onSettle(targetIndex)
|
|
},
|
|
)
|
|
} else {
|
|
// Normal flick settle
|
|
let v = Math.abs(velo)
|
|
let restingDistance = 0
|
|
while (v > FLICK_MIN_VELOCITY) {
|
|
v *= FLICK_DECAY
|
|
restingDistance += v
|
|
}
|
|
|
|
const direction: -1 | 1 = delta < 0 ? -1 : 1
|
|
const totalDistance = Math.abs(delta) + restingDistance
|
|
|
|
const targetIndex = whichByDistance(
|
|
itemWidthsRef.current,
|
|
localIndex,
|
|
totalDistance,
|
|
direction,
|
|
imageCount,
|
|
)
|
|
|
|
const from = el.scrollLeft
|
|
const to = getOffsetForIndex(itemWidthsRef.current, targetIndex)
|
|
|
|
if (from === to) {
|
|
onSettle(targetIndex)
|
|
return
|
|
}
|
|
|
|
stopTween = tween(
|
|
from,
|
|
to,
|
|
SETTLE_DURATION,
|
|
)(
|
|
v => {
|
|
scrollTo(v)
|
|
},
|
|
() => {
|
|
stopTween = null
|
|
onSettle(targetIndex)
|
|
},
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
/*
|
|
* Safari does not support `overscroll-behavior`, so a horizontal trackpad
|
|
* swipe over the carousel can trigger the browser's back/forward
|
|
* navigation gesture. We intercept predominantly-horizontal wheel events
|
|
* and apply the scroll ourselves, calling preventDefault to suppress the
|
|
* history-nav gesture. Vertical-dominant wheel events are left untouched so
|
|
* normal page scroll still works. Chrome/Firefox are covered by the
|
|
* `overscrollBehaviorX: 'contain'` style on the FlatList.
|
|
*
|
|
* Listener must be non-passive so preventDefault is honored.
|
|
*/
|
|
const onWheel = (e: WheelEvent) => {
|
|
if (!IS_WEB_SAFARI) return
|
|
// Only act on predominantly-horizontal scrolls. Vertical-dominant events
|
|
// are page scroll and must not be swallowed.
|
|
if (Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return
|
|
|
|
e.preventDefault()
|
|
|
|
// Cancel any in-progress settle tween so manual scrolling feels direct.
|
|
if (stopTween) {
|
|
stopTween()
|
|
stopTween = null
|
|
}
|
|
if (overscrollX !== 0) clearOverscroll()
|
|
|
|
const maxScroll = el.scrollWidth - el.clientWidth
|
|
const next = Math.max(0, Math.min(el.scrollLeft + e.deltaX, maxScroll))
|
|
scrollTo(next)
|
|
|
|
// Keep the active index in sync so keyboard/lightbox stay correct, but
|
|
// only settle when it actually changes - onSettle moves focus, which we
|
|
// don't want to thrash on every wheel tick.
|
|
let accumulated = 0
|
|
let index = 0
|
|
for (let i = 0; i < imageCount; i++) {
|
|
const w = (itemWidthsRef.current.get(i) ?? 0) + ITEM_GAP
|
|
if (next < accumulated + w / 2) {
|
|
index = i
|
|
break
|
|
}
|
|
accumulated += w
|
|
if (i === imageCount - 1) index = i
|
|
}
|
|
if (index !== localIndex) {
|
|
localIndex = index
|
|
onSettle(index)
|
|
}
|
|
}
|
|
|
|
el.addEventListener('mousedown', onMouseDown)
|
|
el.addEventListener('wheel', onWheel, {passive: false})
|
|
window.addEventListener('mousemove', onMouseMove)
|
|
window.addEventListener('mouseup', onMouseUp)
|
|
|
|
return () => {
|
|
el.removeEventListener('mousedown', onMouseDown)
|
|
el.removeEventListener('wheel', onWheel)
|
|
window.removeEventListener('mousemove', onMouseMove)
|
|
window.removeEventListener('mouseup', onMouseUp)
|
|
if (stopTween) stopTween()
|
|
clearOverscroll()
|
|
el.style.cursor = ''
|
|
el.style.userSelect = ''
|
|
}
|
|
}, [
|
|
flatListRef,
|
|
itemWidthsRef,
|
|
currentIndexRef,
|
|
scrollTo,
|
|
onSettle,
|
|
imageCount,
|
|
])
|
|
}
|