Web in a good place
This commit is contained in:
@@ -28,6 +28,7 @@ import {Text} from '#/components/Typography'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
|
||||
import {useKeyboardHandlers} from '#/components/images/Gallery/useKeyboardHandlers'
|
||||
import {usePointerHandlers} from '#/components/images/Gallery/usePointerHandlers'
|
||||
|
||||
const CONTAINER_ASPECT_RATIO = 3 / 2
|
||||
const ITEM_GAP = 8 // tokens.space.sm
|
||||
@@ -152,6 +153,17 @@ export function Gallery({
|
||||
imageCount: images.length,
|
||||
})
|
||||
|
||||
usePointerHandlers({
|
||||
flatListRef,
|
||||
itemWidthsRef,
|
||||
currentIndexRef,
|
||||
onSettle(index: number) {
|
||||
const el = itemRefsRef.current.get(index) as unknown as HTMLElement | null
|
||||
el?.focus({preventScroll: true})
|
||||
},
|
||||
imageCount: images.length,
|
||||
})
|
||||
|
||||
return (
|
||||
<View
|
||||
ref={contentRef}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
function ease(t: number, b: number, c: number, d: number) {
|
||||
return t === d ? b + c : c * (-Math.pow(2, (-10 * t) / d) + 1) + b
|
||||
}
|
||||
|
||||
/**
|
||||
* Tween from `start` to `end` over `duration` ms using an exponential ease-out.
|
||||
* Returns a function that starts the tween. That function returns a stop handle.
|
||||
*
|
||||
* Adapted from tinkerbell.
|
||||
*/
|
||||
export function tween(start: number, end: number, duration: number) {
|
||||
return function run(cb: (v: number) => void, done?: () => void) {
|
||||
let ts: number | undefined
|
||||
let frame: number
|
||||
|
||||
frame = (function tick(last: number) {
|
||||
return requestAnimationFrame(t => {
|
||||
if (!ts) ts = t
|
||||
const te = t - ts
|
||||
const next = Math.round(ease(te, start, end - start, duration))
|
||||
if (
|
||||
(end > start ? next < end && last <= end : next > end && last >= end) &&
|
||||
te <= duration
|
||||
) {
|
||||
frame = tick(next)
|
||||
cb(next)
|
||||
} else {
|
||||
cb(end)
|
||||
done?.()
|
||||
}
|
||||
})
|
||||
})(start)
|
||||
|
||||
return function stop() {
|
||||
cancelAnimationFrame(frame)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export function usePointerHandlers(_args: {
|
||||
flatListRef: any
|
||||
itemWidthsRef: any
|
||||
currentIndexRef: any
|
||||
onSettle: any
|
||||
imageCount: any
|
||||
}) {}
|
||||
@@ -0,0 +1,212 @@
|
||||
import {useEffect} from 'react'
|
||||
import {type FlatList} from 'react-native'
|
||||
|
||||
import {tween} from '#/components/images/Gallery/tween'
|
||||
|
||||
const ITEM_GAP = 8
|
||||
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 = 600
|
||||
|
||||
function getOffsetForIndex(
|
||||
itemWidths: Map<number, number>,
|
||||
index: number,
|
||||
): number {
|
||||
let offset = 0
|
||||
for (let i = 0; i < index; i++) {
|
||||
offset += (itemWidths.get(i) ?? 0) + ITEM_GAP
|
||||
}
|
||||
return offset
|
||||
}
|
||||
|
||||
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,
|
||||
onSettle,
|
||||
imageCount,
|
||||
}: {
|
||||
flatListRef: React.RefObject<FlatList | null>
|
||||
itemWidthsRef: React.RefObject<Map<number, number>>
|
||||
currentIndexRef: React.RefObject<number>
|
||||
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
|
||||
|
||||
el.style.cursor = 'grab'
|
||||
|
||||
const onMouseDown = (e: MouseEvent) => {
|
||||
e.preventDefault() // prevent native image drag
|
||||
|
||||
// Cancel any in-progress tween
|
||||
if (stopTween) {
|
||||
stopTween()
|
||||
stopTween = null
|
||||
}
|
||||
|
||||
isMouseDown = true
|
||||
isDragging = false
|
||||
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
|
||||
|
||||
el.scrollLeft = dragScrollLeft - delta
|
||||
|
||||
// Update current index from scroll position
|
||||
const offsetX = el.scrollLeft
|
||||
let accumulated = 0
|
||||
for (let i = 0; i < imageCount; i++) {
|
||||
const w = (itemWidthsRef.current.get(i) ?? 0) + ITEM_GAP
|
||||
if (offsetX < accumulated + w / 2) {
|
||||
currentIndexRef.current = i
|
||||
break
|
||||
}
|
||||
accumulated += w
|
||||
if (i === imageCount - 1) currentIndexRef.current = 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,
|
||||
})
|
||||
|
||||
// Estimate resting distance from velocity
|
||||
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,
|
||||
currentIndexRef.current,
|
||||
totalDistance,
|
||||
direction,
|
||||
imageCount,
|
||||
)
|
||||
|
||||
// Tween from current scroll position to target
|
||||
const from = el.scrollLeft
|
||||
const to = getOffsetForIndex(itemWidthsRef.current, targetIndex)
|
||||
|
||||
stopTween = tween(from, to, SETTLE_DURATION)(
|
||||
v => {
|
||||
el.scrollLeft = v
|
||||
},
|
||||
() => {
|
||||
stopTween = null
|
||||
currentIndexRef.current = targetIndex
|
||||
onSettle(targetIndex)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
el.addEventListener('mousedown', onMouseDown)
|
||||
window.addEventListener('mousemove', onMouseMove)
|
||||
window.addEventListener('mouseup', onMouseUp)
|
||||
|
||||
return () => {
|
||||
el.removeEventListener('mousedown', onMouseDown)
|
||||
window.removeEventListener('mousemove', onMouseMove)
|
||||
window.removeEventListener('mouseup', onMouseUp)
|
||||
if (stopTween) stopTween()
|
||||
el.style.cursor = ''
|
||||
el.style.userSelect = ''
|
||||
}
|
||||
}, [flatListRef, itemWidthsRef, currentIndexRef, onSettle, imageCount])
|
||||
}
|
||||
Reference in New Issue
Block a user